1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "net/http"
8 "net/url"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/a-h/templ"
14
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17)
18
19// The owner's inbox (ADR 0031): GET /-/inbox lists the open merge requests and
20// issues of every repository, newest activity first, with a filter by
21// repository (?repo=<name>, a plain link, so it works without scripts). Only a
22// signed-in owner sees it: for anyone else the path is as missing as any other.
23// It follows the stream "owner" with view=inbox: each event of a merge request
24// or an issue sends the counters, the two lists and the filter again.
25
26// inboxPath is the page of the inbox.
27const inboxPath = "/-/inbox"
28
29// inboxEvent is the name of the live events of the inbox.
30const inboxEvent = "inbox"
31
32// InboxRow is one open item of the inbox.
33type InboxRow struct {
34 Repo string
35 RepoHref string
36 Number string
37 Href string
38 Title string
39 When string
40 WhenAt string
41 Cursor bool
42}
43
44// PendingRow is one issue awaiting moderation. Everything in it is plain text:
45// it comes from an anonymous visitor and is never rendered as Markdown.
46type PendingRow struct {
47 Repo string
48 RepoHref string
49 Author string
50 Title string
51 // Preview is the start of the body, at most pendingPreviewRunes characters.
52 Preview string
53 When string
54 WhenAt string
55 // Approve, Reject and Ban are the URLs the buttons post to; empty without
56 // the moderation use case.
57 Approve, Reject, Ban string
58}
59
60// pendingPreviewRunes bounds the preview of a pending body.
61const pendingPreviewRunes = 300
62
63// InboxRepoLink is an entry of the repository filter.
64type InboxRepoLink struct {
65 Label string
66 Href string
67 Current bool
68}
69
70// InboxPage is the inbox: the counters, the filter and the two lists.
71type InboxPage struct {
72 // MergeRequests and Issues count every open item, whatever the filter.
73 MergeRequests, Issues int
74 // PendingCount counts every issue awaiting moderation.
75 PendingCount int
76 Pending []PendingRow
77 Filter string // the repository filtered on, "" for all
78 Repos []InboxRepoLink
79 MRs []InboxRow
80 IssueRows []InboxRow
81}
82
83// inboxHref is the inbox page, filtered on repo when it is not "".
84func inboxHref(repo string) string {
85 if repo == "" {
86 return inboxPath
87 }
88 return inboxPath + "?repo=" + url.QueryEscape(repo)
89}
90
91// inboxFilter reads ?repo=. A name that is not valid is a page that is not
92// there.
93func inboxFilter(r *http.Request) (domain.RepoName, error) {
94 raw := r.URL.Query().Get("repo")
95 if raw == "" {
96 return domain.RepoName{}, nil
97 }
98 name, err := domain.ParseRepoName(raw)
99 if err != nil {
100 return domain.RepoName{}, errNotFound
101 }
102 return name, nil
103}
104
105// inboxPage builds the page from the overview.
106func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
107 now := s.cfg.Now()
108 p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
109 for _, pi := range ov.Pending {
110 p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
111 }
112 row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow {
113 ts := updated
114 if ts.IsZero() {
115 ts = created
116 }
117 r := InboxRow{
118 Repo: repo.String(), RepoHref: repoHref(repo.String()),
119 Number: "#" + strconv.FormatInt(n, 10), Href: href, Title: visibleText(title),
120 }
121 r.When, r.WhenAt = whenOf(now, ts)
122 return r
123 }
124 for _, m := range ov.MergeRequests {
125 p.MRs = append(p.MRs, row(m.Repo, m.Number, mrHref(m.Repo.String(), m.Number), m.Title.String(), m.UpdatedAt, m.CreatedAt))
126 }
127 for _, i := range ov.Issues {
128 p.IssueRows = append(p.IssueRows, row(i.Repo, i.Number, issueHref(i.Repo.String(), i.Number), i.Title.String(), i.UpdatedAt, i.CreatedAt))
129 }
130 switch {
131 case len(p.MRs) > 0:
132 p.MRs[0].Cursor = true
133 case len(p.IssueRows) > 0:
134 p.IssueRows[0].Cursor = true
135 }
136 p.Repos = append(p.Repos, InboxRepoLink{Label: "all", Href: inboxHref(""), Current: filter.IsZero()})
137 for _, r := range ov.Repos {
138 p.Repos = append(p.Repos, InboxRepoLink{Label: r.String(), Href: inboxHref(r.String()), Current: r == filter})
139 }
140 return p
141}
142
143// pendingRow is the row of a pending issue: the texts as plain strings, the
144// invisible characters marked, the body cut.
145func (s *Server) pendingRow(now time.Time, pi domain.PendingIssue, filter domain.RepoName) PendingRow {
146 repo := pi.Repo.String()
147 row := PendingRow{
148 Repo: repo, RepoHref: repoHref(repo), Author: "anonymous",
149 Title: visibleText(pi.Title.String()),
150 }
151 if a := strings.TrimSpace(visibleText(pi.Author)); a != "" {
152 row.Author = a
153 }
154 body := strings.TrimSpace(visibleText(pi.Body.String()))
155 row.Preview = cutRunes(body, pendingPreviewRunes)
156 if len(row.Preview) < len(body) {
157 row.Preview += "…"
158 }
159 row.When, row.WhenAt = whenOf(now, pi.CreatedAt)
160 if s.cfg.Moderation != nil {
161 base := inboxPath + "/pending/" + url.PathEscape(repo) + "/" + url.PathEscape(pi.ID.String()) + "/"
162 var q string
163 if !filter.IsZero() {
164 q = "?repo=" + url.QueryEscape(filter.String())
165 }
166 row.Approve, row.Reject, row.Ban = base+"approve"+q, base+"reject"+q, base+"ban"+q
167 }
168 return row
169}
170
171// pendingBack is where a moderation action returns: the inbox, on the same
172// repository filter.
173func pendingBack(r *http.Request) string {
174 if f, err := inboxFilter(r); err == nil {
175 return inboxHref(f.String())
176 }
177 return inboxPath
178}
179
180// pendingID reads the repository and the id of the path.
181func pendingID(r *http.Request) (domain.RepoName, domain.PendingIssueID, error) {
182 name, err := domain.ParseRepoName(r.PathValue("repo"))
183 if err != nil {
184 return domain.RepoName{}, "", err
185 }
186 id, err := domain.ParsePendingIssueID(r.PathValue("id"))
187 if err != nil {
188 return domain.RepoName{}, "", domain.ErrNotFound
189 }
190 return name, id, nil
191}
192
193// pendingApprove is POST /-/inbox/pending/<repo>/<id>/approve.
194func (s *Server) pendingApprove(ctx context.Context, r *http.Request) (string, string, error) {
195 back := pendingBack(r)
196 name, id, err := pendingID(r)
197 if err != nil {
198 return "", back, err
199 }
200 if _, err := s.cfg.Moderation.Approve(ctx, principal(ctx), name, id); err != nil {
201 return "", back, err
202 }
203 return back, back, nil
204}
205
206// pendingReject is POST /-/inbox/pending/<repo>/<id>/reject, or .../ban, which
207// also bans the submitter.
208func (s *Server) pendingReject(ban bool) func(ctx context.Context, r *http.Request) (string, string, error) {
209 return func(ctx context.Context, r *http.Request) (string, string, error) {
210 back := pendingBack(r)
211 name, id, err := pendingID(r)
212 if err != nil {
213 return "", back, err
214 }
215 if err := s.cfg.Moderation.Reject(ctx, principal(ctx), name, id, ban); err != nil {
216 return "", back, err
217 }
218 return back, back, nil
219 }
220}
221
222// inbox is GET /-/inbox.
223func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) {
224 if !SignedIn(ctx) {
225 return nil, 0, errNotFound
226 }
227 filter, err := inboxFilter(r)
228 if err != nil {
229 return nil, 0, err
230 }
231 ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
232 if err != nil {
233 return nil, 0, err
234 }
235 l := s.base()
236 l.Title = "inbox · " + s.instance
237 l.Crumbs = []Crumb{{Label: "inbox", Current: true}}
238 l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
239 if s.cfg.Events != nil {
240 q := "view=inbox"
241 if !filter.IsZero() {
242 q += "&repo=" + url.QueryEscape(filter.String())
243 }
244 l.Live, l.LiveURL = true, liveURL("owner", q)
245 }
246 return Inbox(l, s.inboxPage(ov, filter)), http.StatusOK, nil
247}
248
249// inboxCount is the number of open items the home button shows, and whether
250// there is a button: only for the signed-in owner, and only when the inbox is
251// there. A failed read leaves the button without a number.
252func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
253 if s.cfg.Inbox == nil || !SignedIn(ctx) {
254 return 0, false
255 }
256 ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), domain.RepoName{})
257 if err != nil {
258 return -1, true
259 }
260 return ov.Counts.MergeRequests + ov.Counts.Issues, true
261}
262
263// inbox renders the fragments an event of a merge request, an issue or a repository changes
264// on the inbox page: the counters, the filter and the two lists.
265func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
266 switch we.Event.(type) {
267 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
268 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
269 domain.RepoUpdated, domain.RepoDeleted:
270 default:
271 return nil, nil
272 }
273 if l.s.cfg.Inbox == nil {
274 return nil, nil
275 }
276 var filter domain.RepoName
277 if l.filter != "" {
278 filter, _ = domain.ParseRepoName(l.filter)
279 }
280 ov, err := l.s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
281 if err != nil {
282 return nil, err
283 }
284 p := l.s.inboxPage(ov, filter)
285 var out []fragment
286 for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {
287 f, err := renderFragment(ctx, inboxEvent, c)
288 if err != nil {
289 return nil, err
290 }
291 out = append(out, f)
292 }
293 return out, nil
294}