detached 67508cc

#4 feat(issues): native issues, owner inbox and moderated public form

state
merged · 67508cc · fast-forward
branch
feat/issue-form → main
opened
bnema ·
updated

Implements ADR 0031: native issues, the owner Inbox, and the moderated public issue form. It contains the three stacked branches feat/issues → feat/inbox → feat/issue-form, and each phase was reviewed before the next.

What it does

  • Issues:
    • Issues share the per-repo number with merge requests, so #n is one or the other and links redirect both ways.
    • New scope issue:write. Agents can create, comment, close and reopen issues without moderation.
    • Per-repo setting issues: off|owner|public, default off. A repo with issues off answers the same 404 as an unknown repo.
    • CLI ermite issue create|list|show|comment|close|reopen and repo edit --issues.
    • Web: an Issues tab, live updates, and owner forms.
  • Inbox (/-/inbox, owner only):
    • Counters at the top, then the sections Awaiting moderation, Open merge requests and Open issues.
    • Repository filter, and an owner live stream across all repositories.
    • Button at the top left of the home page, shown only when signed in.
  • Public form (mode public):
    • JavaScript required. A same-origin worker solves a proof of work signed by the server (18 to 21 bits, adaptive).
    • Honeypot field and a minimum fill time of 3 s.
    • Single-use challenge and form token.
    • Limits of 3/hour and 10/day per network, and a cap of 200 pending issues, both reserved atomically.
    • Bans last 30 days and are keyed on an HMAC of the address. No IP is written to disk.
    • Until the proof of work is valid, every refusal is the same generic 400, so nothing reveals a ban, a limit or a full queue.
    • Moderation (approve, reject, reject and ban) happens in the Inbox and over the API.
    • Approved anonymous issues render untrusted Markdown. Pending issues render as plain text.
  • Storage: schema 3 → 4. The upgrade adds issue:write to stored tokens that already hold every scope, so they keep their owner rights.

Verification

  • Phase reviews (go-reviewer) and a JS review (ts-reviewer). All findings are fixed, including:
    • comment loss after retries;
    • issue events delivered after issues were turned off;
    • the bypass of the per-network limit and of the cap under concurrency;
    • the ban oracle;
    • the emoji/NFC counters;
    • restoring the page from bfcache.
  • Final wrap-up review: every point is fixed except the deferred one listed below.
  • End-to-end test in headless Chromium at production difficulty, measured as:
    • a proof-of-work solve of 0.25 to 4.4 s;
    • moderation, then approval as #1;
    • 403 without Origin, 400 on token reuse, 429 with Retry-After;
    • the no-JS message shown instead of the form.
  • go vet, go test -race ./... (full suite), hexcheck, templ drift, buf generate, the mocks and go mod tidy all pass. All 155 JS tests pass. All commits are signed.

Before leaving draft

  • Merge MR !1 (feat/mr-notes) first. Then rebase this branch on main and extend the v3 → v4 upgrade so that it grants mr:review and issue:write to tokens holding the 9 original scopes. Also restore mr:review in client.AllScopes() and drop the temporary Unimplemented MR note handlers in rpc. Do not deploy main between the two merges: existing all-scope tokens would lose their owner rights.
  • Merge the ermite-api MR (feat/issue-form) and tag it. Replace the local replace bnema.dev/ermite-api with go get bnema.dev/ermite-api@<tag>, then run go mod tidy.
  • Deferred (GO-004, scaling): the home badge and every Inbox event compute the full Overview. This is fine on a solo instance; a lighter count-only path can come later.
  • Behind a reverse proxy, set ERMITE_TRUSTED_PROXIES. Without it, every visitor shares the proxy's per-network limit.
CommitSubjectAuthorDateSignatureStatus
67508cctest(usecase): run the issue moderation subtests in parallelbriceGPG Verified
f6f76c8build(deps): ermite-api v0.9.0briceGPG Verified
7e0e489feat(kvstore): grant mr:review and issue:write to full tokens in the schema 4 upgradebriceGPG Verified
fa37646merge: main with merge request notes into feat/issue-formbriceGPG Verified
346e37fdocs: correct issue access, limiter and comment docsbriceGPG Verified
e53302brefactor(kvstore): keep the hexcheck waiver on issue approvalbriceGPG Verified
ddab9a2fix(rpc): answer not found to non-owners before parsing moderation requestsbriceGPG Verified
f8b1e92refactor: drop the unused pending issue state, ListOpen and InboxMergeRequestbriceGPG Verified
1486f6drefactor(domain): build approved issues in the domainbriceGPG Verified
0284609perf(kvstore): page issues without decoding the whole historybriceGPG Verified
17b5f65fix(kvstore): bound the numbering locksbriceGPG Verified
b0db7adfix(usecase): refresh the inbox after a rejectionbriceGPG Verified
03a1ab8Merge branch 'feat/inbox' into feat/issue-formbriceGPG Verified
0092b37Merge branch 'feat/issues' into feat/inboxbriceGPG Verified
30f7beafix(rpc): build against the api with merge request notes and reject scope mr:reviewbriceGPG Verified
b5344bffix(usecase): verify the proof of work before any admission check and reserve quota and queue atomicallybriceGPG Verified
bc78630fix(web): count issue form limits like the server and refresh the challenge after back navigationbriceGPG Verified
e687d9cdocs: public issue form and moderationbriceGPG Verified
cce8cfafeat(web): public issue form and moderation in the inboxbriceGPG Verified
093dc62feat(web): proof of work solverbriceGPG Verified
3f9aa10feat(web): untrusted markdown for external issuesbriceGPG Verified
99db356feat(usecase): allow public issuesbriceGPG Verified
a26798bfeat(usecase): anonymous issue submission with adaptive proof of workbriceGPG Verified
ee8d39cfeat(rpc): serve pending issue moderation and wire it in the forgebriceGPG Verified
51f0135feat(usecase): add issue moderation, the form subkeys and the owner-only issue.submitted eventbriceGPG Verified
2576c64fix(clientlimit): keep the submission limits free of the domain importbriceGPG Verified
8d2ab49feat(clientlimit): add the sliding window submission limiterbriceGPG Verified
6a3e65dfeat(kvstore): store pending issues, bans and the issue form keybriceGPG Verified
dca1fe7feat(ports): add the pending issue, key, ban, submission limiter and moderation portsbriceGPG Verified
784ea9ffeat(domain): add pending issues, the issue submitted event and the form limitsbriceGPG Verified
babd8c2build(deps): use the local ermite-api issue form branchbriceGPG Verified
d66b108test(events): owner topic access matrix, token end, bearer refusal, stale indexbriceGPG Verified
063865bfix(events): deliver repo.updated and repo.deleted to owner watchers and refresh the inboxbriceGPG Verified
bc6524cfeat(web): owner inbox page and home buttonbriceGPG Verified
ab38ed2feat(events): owner topic across repositoriesbriceGPG Verified
974935dfeat(usecase): owner inbox overview across repositoriesbriceGPG Verified
e17a0e5feat(kvstore): list open merge requests and issues across repositoriesbriceGPG Verified
68460c1fix(kvstore): serialize numbering per repository so concurrent creates never exhaust retriesbriceGPG Verified
b2212f2test(app): merge the declaration with the assignment in the issue e2e test (staticcheck S1021)briceGPG Verified
2794dd4fix(kvstore): guard the repository key when updating an issue so a repo delete leaves no open-issue index keysbriceGPG Verified
02692c4fix(events): stop streaming issue events and views once issues are off or the repo is not visiblebriceGPG Verified
4f0da00fix(app): keep token preset cli working on servers without issues; return issue show write errors; correct issue help limitsbriceGPG Verified
a4f8ebdfix(usecase): report a conflict when every issue comment save conflicts instead of publishing IssueCommentedbriceGPG Verified
e3b6724merge(web): issue pages into feat/issuesbriceGPG Verified
68db1e6feat(web): annotate the issue tab and transition for hexcheckbriceGPG Verified
fd07554docs: document issuesbriceGPG Verified
582cf61feat(cli): add ermite issue commands and repo edit --issuesbriceGPG Verified
931429bfeat(web): add the issue pages, tab, redirects and live noticesbriceGPG Verified
a5d6827feat(rpc): serve IssueService and the issues setting, wire issuesbriceGPG Verified
4c8ce48feat(usecase): add the issue use cases and the issues settingbriceGPG Verified
c52c40ffeat(storage): store issues on the merge request sequence, schema 4briceGPG Verified
b41adf0feat(domain): add issues, the issues mode, text parsers and the issue:write scopebriceGPG Verified
6815c6cbuild(deps): use the local ermite-api issues branchbriceGPG Verified

Checks 8

176 files · +18416 -1025
7/8
M README.md +1 -0
M docs/cli.md +7 -2
M docs/configuration.md +1 -1
A docs/issues.md +55 -0
M docs/tokens.md +2 -1
A gen/ermite/storage/issue.pb.go +541 -0
M gen/ermite/storage/merge_request.pb.go +109 -38
M go.mod +2 -2
M go.sum +2 -2
M internal/adapters/client/client.go +5 -0
M internal/adapters/client/client_test.go +3 -3
M internal/adapters/client/features.go +4 -1
M internal/adapters/client/scopes_test.go +2 -0
M internal/adapters/kvstore/export_test.go +41 -1
A internal/adapters/kvstore/issues.go +437 -0
A internal/adapters/kvstore/issues_pending.go +408 -0
A internal/adapters/kvstore/issues_pending_test.go +271 -0
A internal/adapters/kvstore/issues_test.go +561 -0
M internal/adapters/kvstore/mergerequests.go +123 -26
M internal/adapters/kvstore/mergerequests_test.go +1 -1
M internal/adapters/kvstore/repos.go +3 -1
M internal/adapters/kvstore/store.go +75 -4
M internal/adapters/kvstore/store_test.go +4 -4
M internal/adapters/rpc/api.go +8 -3
M internal/adapters/rpc/convert.go +19 -1
M internal/adapters/rpc/convert_test.go +40 -0
A internal/adapters/rpc/issue.go +262 -0
M internal/adapters/rpc/mergerequest.go +3 -1
M internal/adapters/rpc/mergerequest_test.go +11 -11
M internal/adapters/rpc/repo.go +10 -2
M internal/adapters/web/ASSETS.md +2 -0
M internal/adapters/web/assets.go +3 -1
M internal/adapters/web/events.go +59 -3
M internal/adapters/web/events_test.go +38 -1
M internal/adapters/web/fragments.go +36 -6
M internal/adapters/web/fragments.templ +1 -1
M internal/adapters/web/fragments_templ.go +1 -1
M internal/adapters/web/home.go +3 -0
A internal/adapters/web/inbox.go +294 -0
A internal/adapters/web/inbox.templ +115 -0
A internal/adapters/web/inbox_moderation_test.go +148 -0
A internal/adapters/web/inbox_templ.go +759 -0
A internal/adapters/web/inbox_test.go +310 -0
A internal/adapters/web/issues.go +363 -0
A internal/adapters/web/issues.templ +187 -0
A internal/adapters/web/issues_form.go +276 -0
A internal/adapters/web/issues_form.templ +67 -0
A internal/adapters/web/issues_form_templ.go +421 -0
A internal/adapters/web/issues_form_test.go +345 -0
A internal/adapters/web/issues_post.go +86 -0
A internal/adapters/web/issues_templ.go +885 -0 generated file: collapsed
A internal/adapters/web/issues_test.go +332 -0
@@ -0,0 +1,332 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+func issueRepoView(t testing.TB, mode domain.IssuesMode) ports.RepoView {
23+ t.Helper()
24+ v := publicRepo(t, "proj")
25+ v.Settings.Issues = mode
26+ return v
27+}
28+
29+func testIssue(t testing.TB, n int64, state domain.IssueState, title, body string) domain.Issue {
30+ t.Helper()
31+ ti, err := domain.ParseIssueTitle(title)
32+ require.NoError(t, err)
33+ b, err := domain.ParseIssueBody(body)
34+ require.NoError(t, err)
35+ return domain.Issue{
36+ Repo: repoName(t, "proj"), Number: n, Title: ti, Body: b, State: state, Author: "owner",
37+ CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now().Add(-time.Minute),
38+ }
39+}
40+
41+type issueRig struct {
42+ srv *Server
43+ issues *mocks.IssueService
44+ nums *mocks.IssueNumberResolver
45+ mrs *mocks.MergeRequestService
46+}
47+
48+// newIssueRig serves proj with the given mode, signed in as the owner when
49+// the cookie "tok" is sent.
50+func newIssueRig(t *testing.T, mode domain.IssuesMode, withNumbers bool) *issueRig {
51+ t.Helper()
52+ r := &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}
53+ browse := mocks.NewBrowseService(t)
54+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
55+ r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
56+ cfg := Config{
57+ Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
58+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
59+ }
60+ if withNumbers {
61+ r.nums = mocks.NewIssueNumberResolver(t)
62+ cfg.IssueNumbers = r.nums
63+ }
64+ r.srv = newTestServer(t, cfg)
65+ return r
66+}
67+
68+func (r *issueRig) do(method, path string, owner bool, form url.Values) *httptest.ResponseRecorder {
69+ var body *strings.Reader
70+ if form != nil {
71+ body = strings.NewReader(form.Encode())
72+ } else {
73+ body = strings.NewReader("")
74+ }
75+ req := httptest.NewRequest(method, path, body)
76+ if method == http.MethodPost {
77+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
78+ req.Header.Set("Origin", testPublicURL)
79+ }
80+ if owner {
81+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
82+ }
83+ rec := httptest.NewRecorder()
84+ r.srv.ServeHTTP(rec, req)
85+ return rec
86+}
87+
88+func TestIssueList(t *testing.T) {
89+ t.Parallel()
90+ r := newIssueRig(t, domain.IssuesOwner, false)
91+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateOpen, PageSize: pageSize}).
92+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 3, domain.IssueStateOpen, "crash <b>on</b> start", "")}, NextPageToken: "tok2"}, nil)
93+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateClosed, PageSize: pageSize}).
94+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 2, domain.IssueStateClosed, "old", "")}}, nil)
95+
96+ rec := r.do("GET", "/proj/issues", false, nil)
97+ require.Equal(t, 200, rec.Code, rec.Body.String())
98+ body := rec.Body.String()
99+ assert.Contains(t, body, `href="/proj/issues/3"`)
100+ assert.Contains(t, body, "crash &lt;b&gt;on&lt;/b&gt; start")
101+ assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page")
102+ assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab")
103+ assert.Contains(t, body, `href="/proj/issues" data-key="i"`, "the Issues tab")
104+ assert.NotContains(t, body, "New issue", "no form for an anonymous visitor")
105+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
106+
107+ closed := r.do("GET", "/proj/issues?state=closed", false, nil)
108+ require.Equal(t, 200, closed.Code)
109+ assert.Contains(t, closed.Body.String(), `href="/proj/issues/2"`)
110+
111+ assert.Equal(t, 404, r.do("GET", "/proj/issues?state=weird", false, nil).Code)
112+
113+ owner := r.do("GET", "/proj/issues", true, nil)
114+ require.Equal(t, 200, owner.Code)
115+ assert.Contains(t, owner.Body.String(), `href="/proj/issues/new"`)
116+ assert.Equal(t, "private, no-store", owner.Header().Get("Cache-Control"))
117+}
118+
119+func TestIssueShow(t *testing.T) {
120+ t.Parallel()
121+ r := newIssueRig(t, domain.IssuesPublic, false)
122+ c := domain.IssueComment{ID: 1, Author: "owner", CreatedAt: time.Now()}
123+ c.Text, _ = domain.ParseIssueBody("see https://example.org <script>x</script>")
124+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
125+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", "# Title\n\n**bold**\n\n<script>alert(1)</script>"),
126+ Comments: []domain.IssueComment{c},
127+ }, nil)
128+
129+ anon := r.do("GET", "/proj/issues/5", false, nil)
130+ require.Equal(t, 200, anon.Code, anon.Body.String())
131+ body := anon.Body.String()
132+ assert.Contains(t, body, "<strong>bold</strong>", "Markdown body")
133+ assert.NotContains(t, body, "<script>alert")
134+ assert.Contains(t, body, `id="c1"`)
135+ assert.Contains(t, body, `href="https://example.org"`)
136+ assert.Contains(t, body, "&lt;script&gt;x&lt;/script&gt;")
137+ assert.NotContains(t, body, `class="cform"`)
138+ assert.NotContains(t, body, "/proj/issues/5/close")
139+ assert.Contains(t, body, `view=issue`)
140+
141+ owner := r.do("GET", "/proj/issues/5", true, nil)
142+ require.Equal(t, 200, owner.Code)
143+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/comments"`)
144+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/close"`)
145+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/reopen")
146+
147+ assert.Equal(t, 404, r.do("GET", "/proj/issues/05", false, nil).Code)
148+ assert.Equal(t, 404, r.do("GET", "/proj/issues/x", false, nil).Code)
149+}
150+
151+func TestIssueShowClosedOffersReopen(t *testing.T) {
152+ t.Parallel()
153+ r := newIssueRig(t, domain.IssuesOwner, false)
154+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
155+ Issue: testIssue(t, 5, domain.IssueStateClosed, "bug", ""),
156+ }, nil)
157+ owner := r.do("GET", "/proj/issues/5", true, nil)
158+ require.Equal(t, 200, owner.Code)
159+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/reopen"`)
160+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/close")
161+}
162+
163+func TestIssueNotFound(t *testing.T) {
164+ t.Parallel()
165+ // Mode off: the pages and the tab are not there; the use case is not asked.
166+ off := newIssueRig(t, domain.IssuesOff, false)
167+ for _, p := range []string{"/proj/issues", "/proj/issues/1", "/proj/issues/new"} {
168+ assert.Equal(t, 404, off.do("GET", p, true, nil).Code, p)
169+ }
170+ summary := off.do("GET", "/proj", false, nil)
171+ require.Equal(t, 200, summary.Code)
172+ assert.NotContains(t, summary.Body.String(), `data-key="i"`)
173+
174+ // The mode is unset in the settings: off as well.
175+ unset := newIssueRig(t, "", false)
176+ assert.Equal(t, 404, unset.do("GET", "/proj/issues", false, nil).Code)
177+
178+ // An unknown repository.
179+ assert.Equal(t, 404, off.do("GET", "/nope/issues", false, nil).Code)
180+
181+ // A private repository, for an anonymous visitor.
182+ priv := mocks.NewRepoService(t)
183+ priv.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
184+ browse := mocks.NewBrowseService(t)
185+ srv := newTestServer(t, Config{Repos: priv, Browse: browse, Issues: mocks.NewIssueService(t)})
186+ rec := httptest.NewRecorder()
187+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj/issues/1", nil))
188+ assert.Equal(t, 404, rec.Code)
189+
190+ // The use case says not found for a number nobody has.
191+ r := newIssueRig(t, domain.IssuesOwner, false)
192+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(9)).Return(ports.IssueDetail{}, domain.ErrNotFound)
193+ assert.Equal(t, 404, r.do("GET", "/proj/issues/9", false, nil).Code)
194+}
195+
196+func TestIssueNewForm(t *testing.T) {
197+ t.Parallel()
198+ r := newIssueRig(t, domain.IssuesOwner, false)
199+ assert.Equal(t, 404, r.do("GET", "/proj/issues/new", false, nil).Code, "owner only")
200+ rec := r.do("GET", "/proj/issues/new", true, nil)
201+ require.Equal(t, 200, rec.Code)
202+ assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
203+ assert.Contains(t, rec.Body.String(), `name="title"`)
204+}
205+
206+func TestIssuePosts(t *testing.T) {
207+ t.Parallel()
208+ r := newIssueRig(t, domain.IssuesOwner, false)
209+ name := repoName(t, "proj")
210+ r.issues.EXPECT().Create(mock.Anything, domain.Owner(), name, ports.CreateIssueParams{Title: "t", Body: "a\nb"}).
211+ Return(testIssue(t, 7, domain.IssueStateOpen, "t", ""), nil)
212+ r.issues.EXPECT().AddComment(mock.Anything, domain.Owner(), name, int64(7), "hi\nthere").
213+ Return(domain.IssueComment{ID: 4}, nil)
214+ r.issues.EXPECT().Close(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, nil)
215+ r.issues.EXPECT().Reopen(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, domain.ErrPreconditionFailed)
216+
217+ rec := r.do("POST", "/proj/issues", true, url.Values{"title": {"t"}, "body": {"a\r\nb"}})
218+ assert.Equal(t, 303, rec.Code)
219+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
220+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
221+
222+ rec = r.do("POST", "/proj/issues/7/comments", true, url.Values{"text": {"hi\r\nthere"}})
223+ assert.Equal(t, 303, rec.Code)
224+ assert.Equal(t, "/proj/issues/7#c4", rec.Header().Get("Location"))
225+
226+ rec = r.do("POST", "/proj/issues/7/close", true, nil)
227+ assert.Equal(t, 303, rec.Code)
228+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
229+
230+ rec = r.do("POST", "/proj/issues/7/reopen", true, nil)
231+ assert.Equal(t, 409, rec.Code, "a refused action is a state page")
232+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
233+}
234+
235+func TestIssuePostsRefusedWithoutSession(t *testing.T) {
236+ t.Parallel()
237+ r := newIssueRig(t, domain.IssuesPublic, false) // no write use case call is expected
238+ for _, p := range []string{"/proj/issues", "/proj/issues/7/comments", "/proj/issues/7/close", "/proj/issues/7/reopen"} {
239+ assert.Equal(t, 403, r.do("POST", p, false, url.Values{"title": {"t"}}).Code, p+" anonymous")
240+ req := httptest.NewRequest("POST", p, nil)
241+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
242+ rec := httptest.NewRecorder()
243+ r.srv.ServeHTTP(rec, req)
244+ assert.Equal(t, 403, rec.Code, p+" without Origin")
245+ }
246+}
247+
248+func TestIssueRoutesAbsentWithoutService(t *testing.T) {
249+ t.Parallel()
250+ browse := mocks.NewBrowseService(t)
251+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
252+ srv := newTestServer(t, Config{Repos: reposServing(t, issueRepoView(t, domain.IssuesOwner)), Browse: browse})
253+ for _, p := range []string{"/proj/issues", "/proj/issues/1"} {
254+ rec := httptest.NewRecorder()
255+ srv.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
256+ assert.Equal(t, 404, rec.Code, p)
257+ }
258+ rec := httptest.NewRecorder()
259+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil))
260+ assert.NotContains(t, rec.Body.String(), `data-key="i"`)
261+}
262+
263+func TestIssueNumberRedirects(t *testing.T) {
264+ t.Parallel()
265+ r := newIssueRig(t, domain.IssuesOwner, true)
266+ name := repoName(t, "proj")
267+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything).Return(ports.IssueDetail{}, domain.ErrNotFound)
268+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything, mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
269+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(1)).Return(domain.NumberMergeRequest, nil)
270+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(2)).Return(domain.NumberIssue, nil)
271+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(3)).Return(domain.NumberNone, nil)
272+
273+ rec := r.do("GET", "/proj/issues/1", false, nil)
274+ assert.Equal(t, 303, rec.Code)
275+ assert.Equal(t, "/proj/merge-requests/1", rec.Header().Get("Location"))
276+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
277+
278+ rec = r.do("GET", "/proj/merge-requests/2", false, nil)
279+ assert.Equal(t, 303, rec.Code)
280+ assert.Equal(t, "/proj/issues/2", rec.Header().Get("Location"))
281+
282+ assert.Equal(t, 404, r.do("GET", "/proj/issues/3", false, nil).Code)
283+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/3", false, nil).Code)
284+}
285+
286+func TestIssueNumberNoRedirectWithoutResolver(t *testing.T) {
287+ t.Parallel()
288+ r := newIssueRig(t, domain.IssuesOwner, false)
289+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1)).Return(ports.IssueDetail{}, domain.ErrNotFound)
290+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1), mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
291+ assert.Equal(t, 404, r.do("GET", "/proj/issues/1", false, nil).Code)
292+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/1", false, nil).Code)
293+}
294+
295+func TestIssueLiveNotices(t *testing.T) {
296+ t.Parallel()
297+ r := newIssueRig(t, domain.IssuesOwner, false)
298+ issue := testIssue(t, 5, domain.IssueStateOpen, "bug", "")
299+ cases := []struct {
300+ ev domain.Event
301+ view string
302+ n string
303+ want string // "" : nothing
304+ }{
305+ {domain.IssueOpened{Issue: issue}, "issues", "", "issues changed"},
306+ {domain.IssueClosed{Issue: issue}, "issues", "", "issues changed"},
307+ {domain.IssueOpened{Issue: issue}, "issue", "5", "an issue was opened"},
308+ {domain.IssueClosed{Issue: issue}, "issue", "5", "this issue was closed"},
309+ {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310+ {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311+ {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313+ {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314+ }
315+ for _, c := range cases {
316+ ev := ports.WatchEvent{Event: c.ev}
317+ assert.True(t, liveEvent(ev), c.ev.EventKind())
318+ got, err := (&liveStream{s: r.srv, repo: "proj", view: c.view, n: c.n}).render(t.Context(), ev)
319+ require.NoError(t, err)
320+ if c.want == "" {
321+ assert.Empty(t, got, "%s on %s", c.ev.EventKind(), c.view)
322+ continue
323+ }
324+ require.Len(t, got, 1, "%s on %s", c.ev.EventKind(), c.view)
325+ assert.Equal(t, "mr-notice", got[0].event)
326+ assert.Contains(t, got[0].html, c.want)
327+ }
328+ // MR events do not touch the issue pages.
329+ got, err := (&liveStream{s: r.srv, repo: "proj", view: "issues"}).render(t.Context(), ports.WatchEvent{Event: domain.MergeRequestClosed{}})
330+ require.NoError(t, err)
331+ assert.Empty(t, got)
332+}
@@ -0,0 +1,332 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+func issueRepoView(t testing.TB, mode domain.IssuesMode) ports.RepoView {
23+ t.Helper()
24+ v := publicRepo(t, "proj")
25+ v.Settings.Issues = mode
26+ return v
27+}
28+
29+func testIssue(t testing.TB, n int64, state domain.IssueState, title, body string) domain.Issue {
30+ t.Helper()
31+ ti, err := domain.ParseIssueTitle(title)
32+ require.NoError(t, err)
33+ b, err := domain.ParseIssueBody(body)
34+ require.NoError(t, err)
35+ return domain.Issue{
36+ Repo: repoName(t, "proj"), Number: n, Title: ti, Body: b, State: state, Author: "owner",
37+ CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now().Add(-time.Minute),
38+ }
39+}
40+
41+type issueRig struct {
42+ srv *Server
43+ issues *mocks.IssueService
44+ nums *mocks.IssueNumberResolver
45+ mrs *mocks.MergeRequestService
46+}
47+
48+// newIssueRig serves proj with the given mode, signed in as the owner when
49+// the cookie "tok" is sent.
50+func newIssueRig(t *testing.T, mode domain.IssuesMode, withNumbers bool) *issueRig {
51+ t.Helper()
52+ r := &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}
53+ browse := mocks.NewBrowseService(t)
54+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
55+ r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
56+ cfg := Config{
57+ Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
58+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
59+ }
60+ if withNumbers {
61+ r.nums = mocks.NewIssueNumberResolver(t)
62+ cfg.IssueNumbers = r.nums
63+ }
64+ r.srv = newTestServer(t, cfg)
65+ return r
66+}
67+
68+func (r *issueRig) do(method, path string, owner bool, form url.Values) *httptest.ResponseRecorder {
69+ var body *strings.Reader
70+ if form != nil {
71+ body = strings.NewReader(form.Encode())
72+ } else {
73+ body = strings.NewReader("")
74+ }
75+ req := httptest.NewRequest(method, path, body)
76+ if method == http.MethodPost {
77+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
78+ req.Header.Set("Origin", testPublicURL)
79+ }
80+ if owner {
81+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
82+ }
83+ rec := httptest.NewRecorder()
84+ r.srv.ServeHTTP(rec, req)
85+ return rec
86+}
87+
88+func TestIssueList(t *testing.T) {
89+ t.Parallel()
90+ r := newIssueRig(t, domain.IssuesOwner, false)
91+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateOpen, PageSize: pageSize}).
92+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 3, domain.IssueStateOpen, "crash <b>on</b> start", "")}, NextPageToken: "tok2"}, nil)
93+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateClosed, PageSize: pageSize}).
94+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 2, domain.IssueStateClosed, "old", "")}}, nil)
95+
96+ rec := r.do("GET", "/proj/issues", false, nil)
97+ require.Equal(t, 200, rec.Code, rec.Body.String())
98+ body := rec.Body.String()
99+ assert.Contains(t, body, `href="/proj/issues/3"`)
100+ assert.Contains(t, body, "crash &lt;b&gt;on&lt;/b&gt; start")
101+ assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page")
102+ assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab")
103+ assert.Contains(t, body, `href="/proj/issues" data-key="i"`, "the Issues tab")
104+ assert.NotContains(t, body, "New issue", "no form for an anonymous visitor")
105+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
106+
107+ closed := r.do("GET", "/proj/issues?state=closed", false, nil)
108+ require.Equal(t, 200, closed.Code)
109+ assert.Contains(t, closed.Body.String(), `href="/proj/issues/2"`)
110+
111+ assert.Equal(t, 404, r.do("GET", "/proj/issues?state=weird", false, nil).Code)
112+
113+ owner := r.do("GET", "/proj/issues", true, nil)
114+ require.Equal(t, 200, owner.Code)
115+ assert.Contains(t, owner.Body.String(), `href="/proj/issues/new"`)
116+ assert.Equal(t, "private, no-store", owner.Header().Get("Cache-Control"))
117+}
118+
119+func TestIssueShow(t *testing.T) {
120+ t.Parallel()
121+ r := newIssueRig(t, domain.IssuesPublic, false)
122+ c := domain.IssueComment{ID: 1, Author: "owner", CreatedAt: time.Now()}
123+ c.Text, _ = domain.ParseIssueBody("see https://example.org <script>x</script>")
124+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
125+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", "# Title\n\n**bold**\n\n<script>alert(1)</script>"),
126+ Comments: []domain.IssueComment{c},
127+ }, nil)
128+
129+ anon := r.do("GET", "/proj/issues/5", false, nil)
130+ require.Equal(t, 200, anon.Code, anon.Body.String())
131+ body := anon.Body.String()
132+ assert.Contains(t, body, "<strong>bold</strong>", "Markdown body")
133+ assert.NotContains(t, body, "<script>alert")
134+ assert.Contains(t, body, `id="c1"`)
135+ assert.Contains(t, body, `href="https://example.org"`)
136+ assert.Contains(t, body, "&lt;script&gt;x&lt;/script&gt;")
137+ assert.NotContains(t, body, `class="cform"`)
138+ assert.NotContains(t, body, "/proj/issues/5/close")
139+ assert.Contains(t, body, `view=issue`)
140+
141+ owner := r.do("GET", "/proj/issues/5", true, nil)
142+ require.Equal(t, 200, owner.Code)
143+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/comments"`)
144+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/close"`)
145+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/reopen")
146+
147+ assert.Equal(t, 404, r.do("GET", "/proj/issues/05", false, nil).Code)
148+ assert.Equal(t, 404, r.do("GET", "/proj/issues/x", false, nil).Code)
149+}
150+
151+func TestIssueShowClosedOffersReopen(t *testing.T) {
152+ t.Parallel()
153+ r := newIssueRig(t, domain.IssuesOwner, false)
154+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
155+ Issue: testIssue(t, 5, domain.IssueStateClosed, "bug", ""),
156+ }, nil)
157+ owner := r.do("GET", "/proj/issues/5", true, nil)
158+ require.Equal(t, 200, owner.Code)
159+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/reopen"`)
160+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/close")
161+}
162+
163+func TestIssueNotFound(t *testing.T) {
164+ t.Parallel()
165+ // Mode off: the pages and the tab are not there; the use case is not asked.
166+ off := newIssueRig(t, domain.IssuesOff, false)
167+ for _, p := range []string{"/proj/issues", "/proj/issues/1", "/proj/issues/new"} {
168+ assert.Equal(t, 404, off.do("GET", p, true, nil).Code, p)
169+ }
170+ summary := off.do("GET", "/proj", false, nil)
171+ require.Equal(t, 200, summary.Code)
172+ assert.NotContains(t, summary.Body.String(), `data-key="i"`)
173+
174+ // The mode is unset in the settings: off as well.
175+ unset := newIssueRig(t, "", false)
176+ assert.Equal(t, 404, unset.do("GET", "/proj/issues", false, nil).Code)
177+
178+ // An unknown repository.
179+ assert.Equal(t, 404, off.do("GET", "/nope/issues", false, nil).Code)
180+
181+ // A private repository, for an anonymous visitor.
182+ priv := mocks.NewRepoService(t)
183+ priv.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
184+ browse := mocks.NewBrowseService(t)
185+ srv := newTestServer(t, Config{Repos: priv, Browse: browse, Issues: mocks.NewIssueService(t)})
186+ rec := httptest.NewRecorder()
187+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj/issues/1", nil))
188+ assert.Equal(t, 404, rec.Code)
189+
190+ // The use case says not found for a number nobody has.
191+ r := newIssueRig(t, domain.IssuesOwner, false)
192+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(9)).Return(ports.IssueDetail{}, domain.ErrNotFound)
193+ assert.Equal(t, 404, r.do("GET", "/proj/issues/9", false, nil).Code)
194+}
195+
196+func TestIssueNewForm(t *testing.T) {
197+ t.Parallel()
198+ r := newIssueRig(t, domain.IssuesOwner, false)
199+ assert.Equal(t, 404, r.do("GET", "/proj/issues/new", false, nil).Code, "owner only")
200+ rec := r.do("GET", "/proj/issues/new", true, nil)
201+ require.Equal(t, 200, rec.Code)
202+ assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
203+ assert.Contains(t, rec.Body.String(), `name="title"`)
204+}
205+
206+func TestIssuePosts(t *testing.T) {
207+ t.Parallel()
208+ r := newIssueRig(t, domain.IssuesOwner, false)
209+ name := repoName(t, "proj")
210+ r.issues.EXPECT().Create(mock.Anything, domain.Owner(), name, ports.CreateIssueParams{Title: "t", Body: "a\nb"}).
211+ Return(testIssue(t, 7, domain.IssueStateOpen, "t", ""), nil)
212+ r.issues.EXPECT().AddComment(mock.Anything, domain.Owner(), name, int64(7), "hi\nthere").
213+ Return(domain.IssueComment{ID: 4}, nil)
214+ r.issues.EXPECT().Close(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, nil)
215+ r.issues.EXPECT().Reopen(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, domain.ErrPreconditionFailed)
216+
217+ rec := r.do("POST", "/proj/issues", true, url.Values{"title": {"t"}, "body": {"a\r\nb"}})
218+ assert.Equal(t, 303, rec.Code)
219+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
220+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
221+
222+ rec = r.do("POST", "/proj/issues/7/comments", true, url.Values{"text": {"hi\r\nthere"}})
223+ assert.Equal(t, 303, rec.Code)
224+ assert.Equal(t, "/proj/issues/7#c4", rec.Header().Get("Location"))
225+
226+ rec = r.do("POST", "/proj/issues/7/close", true, nil)
227+ assert.Equal(t, 303, rec.Code)
228+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
229+
230+ rec = r.do("POST", "/proj/issues/7/reopen", true, nil)
231+ assert.Equal(t, 409, rec.Code, "a refused action is a state page")
232+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
233+}
234+
235+func TestIssuePostsRefusedWithoutSession(t *testing.T) {
236+ t.Parallel()
237+ r := newIssueRig(t, domain.IssuesPublic, false) // no write use case call is expected
238+ for _, p := range []string{"/proj/issues", "/proj/issues/7/comments", "/proj/issues/7/close", "/proj/issues/7/reopen"} {
239+ assert.Equal(t, 403, r.do("POST", p, false, url.Values{"title": {"t"}}).Code, p+" anonymous")
240+ req := httptest.NewRequest("POST", p, nil)
241+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
242+ rec := httptest.NewRecorder()
243+ r.srv.ServeHTTP(rec, req)
244+ assert.Equal(t, 403, rec.Code, p+" without Origin")
245+ }
246+}
247+
248+func TestIssueRoutesAbsentWithoutService(t *testing.T) {
249+ t.Parallel()
250+ browse := mocks.NewBrowseService(t)
251+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
252+ srv := newTestServer(t, Config{Repos: reposServing(t, issueRepoView(t, domain.IssuesOwner)), Browse: browse})
253+ for _, p := range []string{"/proj/issues", "/proj/issues/1"} {
254+ rec := httptest.NewRecorder()
255+ srv.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
256+ assert.Equal(t, 404, rec.Code, p)
257+ }
258+ rec := httptest.NewRecorder()
259+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil))
260+ assert.NotContains(t, rec.Body.String(), `data-key="i"`)
261+}
262+
263+func TestIssueNumberRedirects(t *testing.T) {
264+ t.Parallel()
265+ r := newIssueRig(t, domain.IssuesOwner, true)
266+ name := repoName(t, "proj")
267+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything).Return(ports.IssueDetail{}, domain.ErrNotFound)
268+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything, mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
269+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(1)).Return(domain.NumberMergeRequest, nil)
270+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(2)).Return(domain.NumberIssue, nil)
271+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(3)).Return(domain.NumberNone, nil)
272+
273+ rec := r.do("GET", "/proj/issues/1", false, nil)
274+ assert.Equal(t, 303, rec.Code)
275+ assert.Equal(t, "/proj/merge-requests/1", rec.Header().Get("Location"))
276+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
277+
278+ rec = r.do("GET", "/proj/merge-requests/2", false, nil)
279+ assert.Equal(t, 303, rec.Code)
280+ assert.Equal(t, "/proj/issues/2", rec.Header().Get("Location"))
281+
282+ assert.Equal(t, 404, r.do("GET", "/proj/issues/3", false, nil).Code)
283+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/3", false, nil).Code)
284+}
285+
286+func TestIssueNumberNoRedirectWithoutResolver(t *testing.T) {
287+ t.Parallel()
288+ r := newIssueRig(t, domain.IssuesOwner, false)
289+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1)).Return(ports.IssueDetail{}, domain.ErrNotFound)
290+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1), mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
291+ assert.Equal(t, 404, r.do("GET", "/proj/issues/1", false, nil).Code)
292+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/1", false, nil).Code)
293+}
294+
295+func TestIssueLiveNotices(t *testing.T) {
296+ t.Parallel()
297+ r := newIssueRig(t, domain.IssuesOwner, false)
298+ issue := testIssue(t, 5, domain.IssueStateOpen, "bug", "")
299+ cases := []struct {
300+ ev domain.Event
301+ view string
302+ n string
303+ want string // "" : nothing
304+ }{
305+ {domain.IssueOpened{Issue: issue}, "issues", "", "issues changed"},
306+ {domain.IssueClosed{Issue: issue}, "issues", "", "issues changed"},
307+ {domain.IssueOpened{Issue: issue}, "issue", "5", "an issue was opened"},
308+ {domain.IssueClosed{Issue: issue}, "issue", "5", "this issue was closed"},
309+ {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310+ {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311+ {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313+ {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314+ }
315+ for _, c := range cases {
316+ ev := ports.WatchEvent{Event: c.ev}
317+ assert.True(t, liveEvent(ev), c.ev.EventKind())
318+ got, err := (&liveStream{s: r.srv, repo: "proj", view: c.view, n: c.n}).render(t.Context(), ev)
319+ require.NoError(t, err)
320+ if c.want == "" {
321+ assert.Empty(t, got, "%s on %s", c.ev.EventKind(), c.view)
322+ continue
323+ }
324+ require.Len(t, got, 1, "%s on %s", c.ev.EventKind(), c.view)
325+ assert.Equal(t, "mr-notice", got[0].event)
326+ assert.Contains(t, got[0].html, c.want)
327+ }
328+ // MR events do not touch the issue pages.
329+ got, err := (&liveStream{s: r.srv, repo: "proj", view: "issues"}).render(t.Context(), ports.WatchEvent{Event: domain.MergeRequestClosed{}})
330+ require.NoError(t, err)
331+ assert.Empty(t, got)
332+}
A internal/adapters/web/jstest/issue-form.test.mjs +183 -0
@@ -0,0 +1,183 @@
1+// Tests of the public issue form: the proof-of-work solver (static/js/pow-worker.js)
2+// and the pure core of the form (static/js/issue-form.js). Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { createHash } from 'node:crypto';
6+import { test } from 'node:test';
7+
8+globalThis.window = globalThis;
9+globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [], addEventListener() {} };
10+await import('../static/js/pow-worker.js');
11+await import('../static/js/issue-form.js');
12+const P = globalThis.ermitePow;
13+const C = window.ermiteIssueForm.core;
14+
15+// The vector of usecase/issue_pow_vector_test.go: the smallest solution for this
16+// nonce at this difficulty, found by the server's own powHash.
17+const VECTOR = { nonce: 'AAECAwQFBgcICQoLDA0ODw', difficulty: 12, solution: '2491' };
18+
19+test('leadingZeroBits counts the zero bits at the start', () => {
20+ assert.equal(P.leadingZeroBits(Uint8Array.of(0xff)), 0);
21+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x80)), 0);
22+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x40)), 1);
23+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x01)), 7);
24+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x80)), 8);
25+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x00, 0x1f)), 19);
26+ assert.equal(P.leadingZeroBits(Uint8Array.of(0, 0)), 16);
27+ assert.equal(P.leadingZeroBits(Uint8Array.of()), 0);
28+});
29+
30+test('b64urlDecode reads the nonce the server sends', () => {
31+ assert.deepEqual([...P.b64urlDecode(VECTOR.nonce)], [...Array(16).keys()]);
32+ assert.deepEqual([...P.b64urlDecode('-_8')], [0xfb, 0xff]);
33+});
34+
35+test('the hashed bytes are nonce || ":" || decimal solution', () => {
36+ const nonce = P.b64urlDecode(VECTOR.nonce);
37+ const got = P.input(nonce, 2491);
38+ assert.equal(got.length, 16 + 1 + 4);
39+ assert.deepEqual([...got.slice(0, 16)], [...nonce]);
40+ assert.equal(Buffer.from(got.slice(16)).toString('latin1'), ':2491');
41+});
42+
43+test('the solver finds the very solution of the Go vector', async () => {
44+ const sol = await P.solve(P.b64urlDecode(VECTOR.nonce), VECTOR.difficulty);
45+ assert.equal(sol, VECTOR.solution);
46+ const sum = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + sol)])).digest();
47+ assert.ok(P.leadingZeroBits(sum) >= VECTOR.difficulty);
48+ // and no smaller counter works
49+ for (let n = 0; n < Number(sol); n++) {
50+ const s = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + n)])).digest();
51+ assert.ok(P.leadingZeroBits(s) < VECTOR.difficulty, 'counter ' + n);
52+ }
53+});
54+
55+test('the solver is independent of the batch size and reports progress', async () => {
56+ const nonce = P.b64urlDecode(VECTOR.nonce);
57+ const seen = [];
58+ const sol = await P.solve(nonce, VECTOR.difficulty, { batch: 100, onProgress: (n) => { seen.push(n); } });
59+ assert.equal(sol, VECTOR.solution);
60+ assert.deepEqual(seen.slice(0, 2), [100, 200]);
61+ const stopped = await P.solve(nonce, 40, { batch: 10, onProgress: (n) => n >= 30 });
62+ assert.equal(stopped, null, 'stopped by the caller');
63+});
64+
65+test('counterClass: warn from 90 %, over above the maximum', () => {
66+ assert.equal(C.counterClass(0, 120), '');
67+ assert.equal(C.counterClass(107, 120), '');
68+ assert.equal(C.counterClass(108, 120), 'warn');
69+ assert.equal(C.counterClass(120, 120), 'warn');
70+ assert.equal(C.counterClass(121, 120), 'over');
71+ assert.equal(C.counterClass(9000, 10000), 'warn');
72+ assert.equal(C.counterClass(8999, 10000), '');
73+ assert.equal(C.counterClass(41, 40), 'over');
74+});
75+
76+test('length counts characters, not UTF-16 units', () => {
77+ assert.equal(C.length('abc'), 3);
78+ assert.equal(C.length('a😀b'), 3);
79+ assert.equal(C.length(''), 0);
80+});
81+
82+test('state: the button opens only when solved, waited and not over', () => {
83+ const base = { solved: false, now: 1000, minAt: 5000, sending: false, unsupported: false, failed: false, overs: false };
84+ const s = (o) => C.state({ ...base, ...o });
85+ assert.deepEqual(s({}), { disabled: true, status: 'Checking your browser… (this takes a few seconds)' });
86+ assert.equal(s({ solved: true }).disabled, true, 'solved too early');
87+ assert.equal(s({ solved: true, now: 4999 }).disabled, true);
88+ assert.equal(s({ now: 6000 }).disabled, true, 'waited but not solved');
89+ assert.equal(s({ solved: true, now: 5000 }).disabled, false);
90+ assert.equal(s({ solved: true, now: 6000, overs: true }).disabled, true, 'a field is too long');
91+ assert.equal(s({ solved: true, now: 6000, sending: true }).disabled, true, 'no double submit');
92+ assert.equal(s({ solved: true, now: 6000, sending: true }).status, 'Sending…');
93+ assert.equal(s({ unsupported: true }).status, 'Your browser cannot run the check.');
94+ assert.equal(s({ solved: true, now: 6000, unsupported: true }).disabled, true);
95+ assert.equal(s({ failed: true, solved: true, now: 6000 }).disabled, true);
96+});
97+
98+test('serverLength counts NFC code points like the server', () => {
99+ assert.equal(C.serverLength('title', 'e\u0301'.repeat(61)), 61);
100+ assert.equal(C.serverLength('title', '\u0344'.repeat(61)), 122);
101+ assert.equal(C.counterClass(122, 120), 'over');
102+ assert.equal(C.serverLength('title', '😀'.repeat(120)), 120);
103+ assert.equal(C.counterClass(120, 120), 'warn');
104+ assert.equal(C.serverLength('title', '😀'.repeat(121)), 121);
105+ assert.equal(C.counterClass(121, 120), 'over');
106+});
107+
108+test('serverLength drops what the server strips and trims', () => {
109+ assert.equal(C.serverLength('title', ' ab\u202e\u200f\u061c\u2066\u0007\u0085 '), 2);
110+ assert.equal(C.serverLength('title', ' \u202e '), 0);
111+ assert.equal(C.serverLength('author', ' me '), 2);
112+ assert.equal(C.serverLength('body', 'a\nb\tc\u0007\u202a\r\n'), 6);
113+ assert.equal(C.serverLength('body', ' a '), 3);
114+ assert.equal(C.serverLength('title', 'a\tb'), 2);
115+});
116+
117+test('state keeps the button disabled when a title is empty or a field is over', () => {
118+ const base = { solved: true, now: 2, minAt: 1, sending: false, unsupported: false, failed: false, overs: false, empty: false };
119+ assert.equal(C.state(base).disabled, false);
120+ assert.equal(C.state({ ...base, overs: true }).disabled, true);
121+ assert.equal(C.state({ ...base, empty: true }).disabled, true);
122+});
123+
124+function fakeStorage() {
125+ const m = new Map();
126+ return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), m };
127+}
128+
129+test('drafts are saved, restored once, and scoped to the path', () => {
130+ const st = fakeStorage();
131+ const k = C.draftKey('/r/issues/new');
132+ assert.notEqual(k, C.draftKey('/other/issues/new'));
133+ C.saveDraft(st, k, { title: 't', body: 'b\nc', author: 'a', solution: 'x' });
134+ assert.deepEqual(C.restoreDraft(st, k), { title: 't', body: 'b\nc', author: 'a' });
135+ assert.equal(st.m.size, 0);
136+ assert.equal(C.restoreDraft(st, k), null);
137+ st.setItem(k, 'not json');
138+ assert.equal(C.restoreDraft(st, k), null);
139+});
140+
141+test('onPageShow reloads only for a bfcache restore', () => {
142+ assert.equal(C.onPageShow(true), 'reload');
143+ assert.equal(C.onPageShow(false), 'none');
144+ assert.equal(C.onPageShow(undefined), 'none');
145+});
146+
147+test('init restores a draft and reloads on a bfcache pageshow', () => {
148+ const listeners = {};
149+ const mk = (o) => ({ listeners: {}, addEventListener(t, f) { this.listeners[t] = f; }, ...o });
150+ const title = mk({ id: 'issue-title', name: 'title', value: '' });
151+ const body = mk({ id: 'issue-body', name: 'body', value: '' });
152+ const author = mk({ id: 'issue-author', name: 'author', value: '' });
153+ const outs = [['issue-title', 120], ['issue-body', 10000], ['issue-author', 40]].map(([f, max]) => ({ dataset: { for: f, max: String(max) }, textContent: '', className: '' }));
154+ const button = { disabled: false };
155+ const solution = { value: '' };
156+ const sel = { 'button[type="submit"]': button, '[data-status]': { textContent: '' }, 'input[name="solution"]': solution,
157+ '[name="title"]': title, '[name="body"]': body, '[name="author"]': author,
158+ '#issue-title': title, '#issue-body': body, '#issue-author': author };
159+ const form = { dataset: { minSubmitAt: '0' }, hidden: true, querySelector: (s) => sel[s] ?? null,
160+ querySelectorAll: () => outs, addEventListener() {}, removeAttribute() {} };
161+ const st = fakeStorage();
162+ let reloads = 0;
163+ const saved = { sessionStorage: window.sessionStorage, location: window.location, addEventListener: window.addEventListener, Worker: window.Worker };
164+ Object.assign(window, { sessionStorage: st, location: { pathname: '/r/issues/new', reload() { reloads++; } },
165+ addEventListener(t, f) { listeners[t] = f; }, Worker: undefined });
166+ try {
167+ C.saveDraft(st, C.draftKey('/r/issues/new'), { title: 'Hello', body: 'B', author: 'me' });
168+ window.ermiteIssueForm.init(form);
169+ assert.equal(title.value, 'Hello');
170+ assert.equal(outs[0].textContent, '5 / 120');
171+ assert.equal(st.m.size, 0);
172+ assert.equal(button.disabled, true);
173+ listeners.pageshow({ persisted: false });
174+ assert.equal(reloads, 0);
175+ title.value = 'Edited';
176+ listeners.pageshow({ persisted: true });
177+ assert.equal(reloads, 1);
178+ assert.deepEqual(JSON.parse(st.getItem(C.draftKey('/r/issues/new'))), { title: 'Edited', body: 'B', author: 'me' });
179+ assert.equal(button.disabled, true);
180+ } finally {
181+ for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete window[k]; else window[k] = v; }
182+ }
183+});
@@ -0,0 +1,183 @@
1+// Tests of the public issue form: the proof-of-work solver (static/js/pow-worker.js)
2+// and the pure core of the form (static/js/issue-form.js). Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { createHash } from 'node:crypto';
6+import { test } from 'node:test';
7+
8+globalThis.window = globalThis;
9+globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [], addEventListener() {} };
10+await import('../static/js/pow-worker.js');
11+await import('../static/js/issue-form.js');
12+const P = globalThis.ermitePow;
13+const C = window.ermiteIssueForm.core;
14+
15+// The vector of usecase/issue_pow_vector_test.go: the smallest solution for this
16+// nonce at this difficulty, found by the server's own powHash.
17+const VECTOR = { nonce: 'AAECAwQFBgcICQoLDA0ODw', difficulty: 12, solution: '2491' };
18+
19+test('leadingZeroBits counts the zero bits at the start', () => {
20+ assert.equal(P.leadingZeroBits(Uint8Array.of(0xff)), 0);
21+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x80)), 0);
22+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x40)), 1);
23+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x01)), 7);
24+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x80)), 8);
25+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x00, 0x1f)), 19);
26+ assert.equal(P.leadingZeroBits(Uint8Array.of(0, 0)), 16);
27+ assert.equal(P.leadingZeroBits(Uint8Array.of()), 0);
28+});
29+
30+test('b64urlDecode reads the nonce the server sends', () => {
31+ assert.deepEqual([...P.b64urlDecode(VECTOR.nonce)], [...Array(16).keys()]);
32+ assert.deepEqual([...P.b64urlDecode('-_8')], [0xfb, 0xff]);
33+});
34+
35+test('the hashed bytes are nonce || ":" || decimal solution', () => {
36+ const nonce = P.b64urlDecode(VECTOR.nonce);
37+ const got = P.input(nonce, 2491);
38+ assert.equal(got.length, 16 + 1 + 4);
39+ assert.deepEqual([...got.slice(0, 16)], [...nonce]);
40+ assert.equal(Buffer.from(got.slice(16)).toString('latin1'), ':2491');
41+});
42+
43+test('the solver finds the very solution of the Go vector', async () => {
44+ const sol = await P.solve(P.b64urlDecode(VECTOR.nonce), VECTOR.difficulty);
45+ assert.equal(sol, VECTOR.solution);
46+ const sum = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + sol)])).digest();
47+ assert.ok(P.leadingZeroBits(sum) >= VECTOR.difficulty);
48+ // and no smaller counter works
49+ for (let n = 0; n < Number(sol); n++) {
50+ const s = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + n)])).digest();
51+ assert.ok(P.leadingZeroBits(s) < VECTOR.difficulty, 'counter ' + n);
52+ }
53+});
54+
55+test('the solver is independent of the batch size and reports progress', async () => {
56+ const nonce = P.b64urlDecode(VECTOR.nonce);
57+ const seen = [];
58+ const sol = await P.solve(nonce, VECTOR.difficulty, { batch: 100, onProgress: (n) => { seen.push(n); } });
59+ assert.equal(sol, VECTOR.solution);
60+ assert.deepEqual(seen.slice(0, 2), [100, 200]);
61+ const stopped = await P.solve(nonce, 40, { batch: 10, onProgress: (n) => n >= 30 });
62+ assert.equal(stopped, null, 'stopped by the caller');
63+});
64+
65+test('counterClass: warn from 90 %, over above the maximum', () => {
66+ assert.equal(C.counterClass(0, 120), '');
67+ assert.equal(C.counterClass(107, 120), '');
68+ assert.equal(C.counterClass(108, 120), 'warn');
69+ assert.equal(C.counterClass(120, 120), 'warn');
70+ assert.equal(C.counterClass(121, 120), 'over');
71+ assert.equal(C.counterClass(9000, 10000), 'warn');
72+ assert.equal(C.counterClass(8999, 10000), '');
73+ assert.equal(C.counterClass(41, 40), 'over');
74+});
75+
76+test('length counts characters, not UTF-16 units', () => {
77+ assert.equal(C.length('abc'), 3);
78+ assert.equal(C.length('a😀b'), 3);
79+ assert.equal(C.length(''), 0);
80+});
81+
82+test('state: the button opens only when solved, waited and not over', () => {
83+ const base = { solved: false, now: 1000, minAt: 5000, sending: false, unsupported: false, failed: false, overs: false };
84+ const s = (o) => C.state({ ...base, ...o });
85+ assert.deepEqual(s({}), { disabled: true, status: 'Checking your browser… (this takes a few seconds)' });
86+ assert.equal(s({ solved: true }).disabled, true, 'solved too early');
87+ assert.equal(s({ solved: true, now: 4999 }).disabled, true);
88+ assert.equal(s({ now: 6000 }).disabled, true, 'waited but not solved');
89+ assert.equal(s({ solved: true, now: 5000 }).disabled, false);
90+ assert.equal(s({ solved: true, now: 6000, overs: true }).disabled, true, 'a field is too long');
91+ assert.equal(s({ solved: true, now: 6000, sending: true }).disabled, true, 'no double submit');
92+ assert.equal(s({ solved: true, now: 6000, sending: true }).status, 'Sending…');
93+ assert.equal(s({ unsupported: true }).status, 'Your browser cannot run the check.');
94+ assert.equal(s({ solved: true, now: 6000, unsupported: true }).disabled, true);
95+ assert.equal(s({ failed: true, solved: true, now: 6000 }).disabled, true);
96+});
97+
98+test('serverLength counts NFC code points like the server', () => {
99+ assert.equal(C.serverLength('title', 'e\u0301'.repeat(61)), 61);
100+ assert.equal(C.serverLength('title', '\u0344'.repeat(61)), 122);
101+ assert.equal(C.counterClass(122, 120), 'over');
102+ assert.equal(C.serverLength('title', '😀'.repeat(120)), 120);
103+ assert.equal(C.counterClass(120, 120), 'warn');
104+ assert.equal(C.serverLength('title', '😀'.repeat(121)), 121);
105+ assert.equal(C.counterClass(121, 120), 'over');
106+});
107+
108+test('serverLength drops what the server strips and trims', () => {
109+ assert.equal(C.serverLength('title', ' ab\u202e\u200f\u061c\u2066\u0007\u0085 '), 2);
110+ assert.equal(C.serverLength('title', ' \u202e '), 0);
111+ assert.equal(C.serverLength('author', ' me '), 2);
112+ assert.equal(C.serverLength('body', 'a\nb\tc\u0007\u202a\r\n'), 6);
113+ assert.equal(C.serverLength('body', ' a '), 3);
114+ assert.equal(C.serverLength('title', 'a\tb'), 2);
115+});
116+
117+test('state keeps the button disabled when a title is empty or a field is over', () => {
118+ const base = { solved: true, now: 2, minAt: 1, sending: false, unsupported: false, failed: false, overs: false, empty: false };
119+ assert.equal(C.state(base).disabled, false);
120+ assert.equal(C.state({ ...base, overs: true }).disabled, true);
121+ assert.equal(C.state({ ...base, empty: true }).disabled, true);
122+});
123+
124+function fakeStorage() {
125+ const m = new Map();
126+ return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), m };
127+}
128+
129+test('drafts are saved, restored once, and scoped to the path', () => {
130+ const st = fakeStorage();
131+ const k = C.draftKey('/r/issues/new');
132+ assert.notEqual(k, C.draftKey('/other/issues/new'));
133+ C.saveDraft(st, k, { title: 't', body: 'b\nc', author: 'a', solution: 'x' });
134+ assert.deepEqual(C.restoreDraft(st, k), { title: 't', body: 'b\nc', author: 'a' });
135+ assert.equal(st.m.size, 0);
136+ assert.equal(C.restoreDraft(st, k), null);
137+ st.setItem(k, 'not json');
138+ assert.equal(C.restoreDraft(st, k), null);
139+});
140+
141+test('onPageShow reloads only for a bfcache restore', () => {
142+ assert.equal(C.onPageShow(true), 'reload');
143+ assert.equal(C.onPageShow(false), 'none');
144+ assert.equal(C.onPageShow(undefined), 'none');
145+});
146+
147+test('init restores a draft and reloads on a bfcache pageshow', () => {
148+ const listeners = {};
149+ const mk = (o) => ({ listeners: {}, addEventListener(t, f) { this.listeners[t] = f; }, ...o });
150+ const title = mk({ id: 'issue-title', name: 'title', value: '' });
151+ const body = mk({ id: 'issue-body', name: 'body', value: '' });
152+ const author = mk({ id: 'issue-author', name: 'author', value: '' });
153+ const outs = [['issue-title', 120], ['issue-body', 10000], ['issue-author', 40]].map(([f, max]) => ({ dataset: { for: f, max: String(max) }, textContent: '', className: '' }));
154+ const button = { disabled: false };
155+ const solution = { value: '' };
156+ const sel = { 'button[type="submit"]': button, '[data-status]': { textContent: '' }, 'input[name="solution"]': solution,
157+ '[name="title"]': title, '[name="body"]': body, '[name="author"]': author,
158+ '#issue-title': title, '#issue-body': body, '#issue-author': author };
159+ const form = { dataset: { minSubmitAt: '0' }, hidden: true, querySelector: (s) => sel[s] ?? null,
160+ querySelectorAll: () => outs, addEventListener() {}, removeAttribute() {} };
161+ const st = fakeStorage();
162+ let reloads = 0;
163+ const saved = { sessionStorage: window.sessionStorage, location: window.location, addEventListener: window.addEventListener, Worker: window.Worker };
164+ Object.assign(window, { sessionStorage: st, location: { pathname: '/r/issues/new', reload() { reloads++; } },
165+ addEventListener(t, f) { listeners[t] = f; }, Worker: undefined });
166+ try {
167+ C.saveDraft(st, C.draftKey('/r/issues/new'), { title: 'Hello', body: 'B', author: 'me' });
168+ window.ermiteIssueForm.init(form);
169+ assert.equal(title.value, 'Hello');
170+ assert.equal(outs[0].textContent, '5 / 120');
171+ assert.equal(st.m.size, 0);
172+ assert.equal(button.disabled, true);
173+ listeners.pageshow({ persisted: false });
174+ assert.equal(reloads, 0);
175+ title.value = 'Edited';
176+ listeners.pageshow({ persisted: true });
177+ assert.equal(reloads, 1);
178+ assert.deepEqual(JSON.parse(st.getItem(C.draftKey('/r/issues/new'))), { title: 'Edited', body: 'B', author: 'me' });
179+ assert.equal(button.disabled, true);
180+ } finally {
181+ for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete window[k]; else window[k] = v; }
182+ }
183+});
M internal/adapters/web/keys.templ +1 -0
@@ -27,6 +27,7 @@ templ Keys() {
2727 k-c from:body -> ermite.tab('c');
2828 k-r from:body -> ermite.tab('r');
2929 k-m from:body -> ermite.tab('m');
30+ k-i from:body -> ermite.tab('i');
3031 k-next from:body -> ermite.nextNote();
3132 k-split from:body -> ermite.layout('split');
3233 k-unified from:body -> ermite.layout('unified');
@@ -27,6 +27,7 @@ templ Keys() {
27 k-c from:body -> ermite.tab('c');27 k-c from:body -> ermite.tab('c');
28 k-r from:body -> ermite.tab('r');28 k-r from:body -> ermite.tab('r');
29 k-m from:body -> ermite.tab('m');29 k-m from:body -> ermite.tab('m');
30+ k-i from:body -> ermite.tab('i');
30 k-next from:body -> ermite.nextNote();31 k-next from:body -> ermite.nextNote();
31 k-split from:body -> ermite.layout('split');32 k-split from:body -> ermite.layout('split');
32 k-unified from:body -> ermite.layout('unified');33 k-unified from:body -> ermite.layout('unified');
M internal/adapters/web/keys_templ.go +1 -1
@@ -34,7 +34,7 @@ func Keys() templ.Component {
3434 templ_7745c5c3_Var1 = templ.NopComponent
3535 }
3636 ctx = templ.ClearChildren(ctx)
37- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-right from:body -> ermite.rail(true);\n k-left from:body -> ermite.rail(false);\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-expand from:body -> ermite.expandFile();\n k-expandAll from:body -> ermite.expandAll();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles();\n k-keys from:body -> ermite.toggleKeys()\"></div>")
37+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-right from:body -> ermite.rail(true);\n k-left from:body -> ermite.rail(false);\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-i from:body -> ermite.tab('i');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-expand from:body -> ermite.expandFile();\n k-expandAll from:body -> ermite.expandAll();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles();\n k-keys from:body -> ermite.toggleKeys()\"></div>")
3838 if templ_7745c5c3_Err != nil {
3939 return templ_7745c5c3_Err
4040 }
@@ -34,7 +34,7 @@ func Keys() templ.Component {
34 templ_7745c5c3_Var1 = templ.NopComponent34 templ_7745c5c3_Var1 = templ.NopComponent
35 }35 }
36 ctx = templ.ClearChildren(ctx)36 ctx = templ.ClearChildren(ctx)
37- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-right from:body -> ermite.rail(true);\n k-left from:body -> ermite.rail(false);\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-expand from:body -> ermite.expandFile();\n k-expandAll from:body -> ermite.expandAll();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles();\n k-keys from:body -> ermite.toggleKeys()\"></div>")37+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-right from:body -> ermite.rail(true);\n k-left from:body -> ermite.rail(false);\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-i from:body -> ermite.tab('i');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-expand from:body -> ermite.expandFile();\n k-expandAll from:body -> ermite.expandAll();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles();\n k-keys from:body -> ermite.toggleKeys()\"></div>")
38 if templ_7745c5c3_Err != nil {38 if templ_7745c5c3_Err != nil {
39 return templ_7745c5c3_Err39 return templ_7745c5c3_Err
40 }40 }
M internal/adapters/web/markdown.go +73 -7
@@ -64,19 +64,31 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
6464 // #fragment links chosen, so that several documents on one page (the summaries
6565 // of a run) do not share ids.
6666 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
67- return m.renderDocument(source, repo, ref, dir, idPrefix, nil)
67+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)
6868 }
6969
7070 // renderNote uses the repository Markdown policy, then adds local note links.
7171 func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
72- h, _, err := m.renderDocument(source, repo, ref, "", idPrefix, notes)
72+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)
7373 return h, err
7474 }
7575
76-func (m *markdown) renderDocument(source []byte, repo, ref, dir, idPrefix string, notes func(int64) bool) (template.HTML, []Heading, error) {
77- pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))
76+// renderUntrusted renders Markdown written by an anonymous visitor (an issue
77+// approved from the public form). On top of the policy of render, which already
78+// drops raw HTML and never loads an image:
79+// - only http, https and mailto links are kept, every one with
80+// rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
81+// every other scheme lose their link and keep the text;
82+// - nothing points into the repository.
83+func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
84+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)
85+ return h, err
86+}
87+
88+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {
89+ pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
7890 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
79- pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}
91+ pol.source = source
8092 // Collect eligible text before the policy unwraps rejected links: their
8193 // labels must not accidentally become references either.
8294 var refs []*ast.Text
@@ -163,6 +175,20 @@ type linkPolicy struct {
163175 source []byte
164176 // idPrefix is the prefix of the heading ids that #fragment links point at.
165177 idPrefix string
178+ // untrusted is the policy of text from an anonymous visitor.
179+ untrusted bool
180+}
181+
182+const (
183+ relExternal = "nofollow noopener noreferrer"
184+ relUntrusted = "nofollow noopener noreferrer ugc"
185+)
186+
187+func (p linkPolicy) rel() []byte {
188+ if p.untrusted {
189+ return []byte(relUntrusted)
190+ }
191+ return []byte(relExternal)
166192 }
167193
168194 func (p linkPolicy) apply(doc ast.Node) {
@@ -195,13 +221,17 @@ func (p linkPolicy) apply(doc ast.Node) {
195221 l.Destination = []byte(dest)
196222 l.Title = nil
197223 if kind == external {
198- l.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
224+ l.SetAttributeString("rel", p.rel())
199225 }
200226 }
201227 for _, n := range autos {
202228 a := n.(*ast.AutoLink)
203229 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
204- a.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
230+ a.SetAttributeString("rel", p.rel())
231+ continue
232+ }
233+ if p.untrusted && a.AutoLinkType == ast.AutoLinkEmail && validMailbox(string(a.Label(p.source))) {
234+ a.SetAttributeString("rel", p.rel())
205235 continue
206236 }
207237 // An email address or another scheme: the label as plain text.
@@ -241,6 +271,9 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
241271 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
242272 return "", dropped
243273 }
274+ if p.untrusted {
275+ return untrustedTarget(dest)
276+ }
244277 if strings.HasPrefix(dest, "#") {
245278 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
246279 if frag == "" || strings.ContainsAny(frag, " \"'<>") {
@@ -288,6 +321,39 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
288321 return href, internal
289322 }
290323
324+// untrustedTarget keeps absolute http and https links with a host, and mailto
325+// links to one plain address.
326+func untrustedTarget(dest string) (string, linkKind) {
327+ if strings.HasPrefix(dest, "//") {
328+ return "", dropped
329+ }
330+ u, err := url.Parse(dest)
331+ if err != nil {
332+ return "", dropped
333+ }
334+ switch strings.ToLower(u.Scheme) {
335+ case "http", "https":
336+ if u.Host == "" || u.User != nil {
337+ return "", dropped
338+ }
339+ return u.String(), external
340+ case "mailto":
341+ if u.Opaque == "" || u.RawQuery != "" || !validMailbox(u.Opaque) {
342+ return "", dropped
343+ }
344+ return "mailto:" + u.Opaque, external
345+ }
346+ return "", dropped
347+}
348+
349+// validMailbox tells whether s is a bare address: no space, quote, angle
350+// bracket, percent sign, comma or second at sign.
351+func validMailbox(s string) bool {
352+ local, host, ok := strings.Cut(s, "@")
353+ return ok && local != "" && host != "" && len(s) <= 254 &&
354+ !strings.ContainsAny(local+host, " \t\r\n\"'<>%,;?#\\@")
355+}
356+
291357 func isWebURL(s string) bool {
292358 u, err := url.Parse(s)
293359 if err != nil || u.Host == "" {
@@ -64,19 +64,31 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
64 // #fragment links chosen, so that several documents on one page (the summaries64 // #fragment links chosen, so that several documents on one page (the summaries
65 // of a run) do not share ids.65 // of a run) do not share ids.
66 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {66 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
67- return m.renderDocument(source, repo, ref, dir, idPrefix, nil)67+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)
68 }68 }
69 69
70 // renderNote uses the repository Markdown policy, then adds local note links.70 // renderNote uses the repository Markdown policy, then adds local note links.
71 func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {71 func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
72- h, _, err := m.renderDocument(source, repo, ref, "", idPrefix, notes)72+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)
73 return h, err73 return h, err
74 }74 }
75 75
76-func (m *markdown) renderDocument(source []byte, repo, ref, dir, idPrefix string, notes func(int64) bool) (template.HTML, []Heading, error) {76+// renderUntrusted renders Markdown written by an anonymous visitor (an issue
77- pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))77+// approved from the public form). On top of the policy of render, which already
78+// drops raw HTML and never loads an image:
79+// - only http, https and mailto links are kept, every one with
80+// rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
81+// every other scheme lose their link and keep the text;
82+// - nothing points into the repository.
83+func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
84+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)
85+ return h, err
86+}
87+
88+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {
89+ pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
78 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))90 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
79- pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}91+ pol.source = source
80 // Collect eligible text before the policy unwraps rejected links: their92 // Collect eligible text before the policy unwraps rejected links: their
81 // labels must not accidentally become references either.93 // labels must not accidentally become references either.
82 var refs []*ast.Text94 var refs []*ast.Text
@@ -163,6 +175,20 @@ type linkPolicy struct {
163 source []byte175 source []byte
164 // idPrefix is the prefix of the heading ids that #fragment links point at.176 // idPrefix is the prefix of the heading ids that #fragment links point at.
165 idPrefix string177 idPrefix string
178+ // untrusted is the policy of text from an anonymous visitor.
179+ untrusted bool
180+}
181+
182+const (
183+ relExternal = "nofollow noopener noreferrer"
184+ relUntrusted = "nofollow noopener noreferrer ugc"
185+)
186+
187+func (p linkPolicy) rel() []byte {
188+ if p.untrusted {
189+ return []byte(relUntrusted)
190+ }
191+ return []byte(relExternal)
166 }192 }
167 193
168 func (p linkPolicy) apply(doc ast.Node) {194 func (p linkPolicy) apply(doc ast.Node) {
@@ -195,13 +221,17 @@ func (p linkPolicy) apply(doc ast.Node) {
195 l.Destination = []byte(dest)221 l.Destination = []byte(dest)
196 l.Title = nil222 l.Title = nil
197 if kind == external {223 if kind == external {
198- l.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))224+ l.SetAttributeString("rel", p.rel())
199 }225 }
200 }226 }
201 for _, n := range autos {227 for _, n := range autos {
202 a := n.(*ast.AutoLink)228 a := n.(*ast.AutoLink)
203 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {229 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
204- a.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))230+ a.SetAttributeString("rel", p.rel())
231+ continue
232+ }
233+ if p.untrusted && a.AutoLinkType == ast.AutoLinkEmail && validMailbox(string(a.Label(p.source))) {
234+ a.SetAttributeString("rel", p.rel())
205 continue235 continue
206 }236 }
207 // An email address or another scheme: the label as plain text.237 // An email address or another scheme: the label as plain text.
@@ -241,6 +271,9 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
241 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {271 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
242 return "", dropped272 return "", dropped
243 }273 }
274+ if p.untrusted {
275+ return untrustedTarget(dest)
276+ }
244 if strings.HasPrefix(dest, "#") {277 if strings.HasPrefix(dest, "#") {
245 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))278 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
246 if frag == "" || strings.ContainsAny(frag, " \"'<>") {279 if frag == "" || strings.ContainsAny(frag, " \"'<>") {
@@ -288,6 +321,39 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
288 return href, internal321 return href, internal
289 }322 }
290 323
324+// untrustedTarget keeps absolute http and https links with a host, and mailto
325+// links to one plain address.
326+func untrustedTarget(dest string) (string, linkKind) {
327+ if strings.HasPrefix(dest, "//") {
328+ return "", dropped
329+ }
330+ u, err := url.Parse(dest)
331+ if err != nil {
332+ return "", dropped
333+ }
334+ switch strings.ToLower(u.Scheme) {
335+ case "http", "https":
336+ if u.Host == "" || u.User != nil {
337+ return "", dropped
338+ }
339+ return u.String(), external
340+ case "mailto":
341+ if u.Opaque == "" || u.RawQuery != "" || !validMailbox(u.Opaque) {
342+ return "", dropped
343+ }
344+ return "mailto:" + u.Opaque, external
345+ }
346+ return "", dropped
347+}
348+
349+// validMailbox tells whether s is a bare address: no space, quote, angle
350+// bracket, percent sign, comma or second at sign.
351+func validMailbox(s string) bool {
352+ local, host, ok := strings.Cut(s, "@")
353+ return ok && local != "" && host != "" && len(s) <= 254 &&
354+ !strings.ContainsAny(local+host, " \t\r\n\"'<>%,;?#\\@")
355+}
356+
291 func isWebURL(s string) bool {357 func isWebURL(s string) bool {
292 u, err := url.Parse(s)358 u, err := url.Parse(s)
293 if err != nil || u.Host == "" {359 if err != nil || u.Host == "" {
M internal/adapters/web/markdown_test.go +67 -0
@@ -137,6 +137,73 @@ func TestMarkdownGFM(t *testing.T) {
137137 assert.Contains(t, out, `type="checkbox"`)
138138 }
139139
140+func renderUntrustedMD(t *testing.T, src string) string {
141+ t.Helper()
142+ h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-")
143+ require.NoError(t, err)
144+ return string(h)
145+}
146+
147+// An issue from the public form is rendered with the strictest policy.
148+func TestMarkdownUntrusted(t *testing.T) {
149+ t.Parallel()
150+ tests := []struct {
151+ name, src string
152+ absent []string
153+ present []string
154+ }{
155+ {"script", "<script>alert(1)</script>\n", []string{"<script", "alert(1)</"}, nil},
156+ {"img onerror", "a <img src=x onerror=alert(1)> b\n", []string{"<img", "onerror"}, []string{"<p>a "}},
157+ {"javascript link", "[x](javascript:alert(1))\n", []string{"javascript", "href"}, []string{"<p>x</p>"}},
158+ {"data link", "[x](data:text/html,<script>alert(1)</script>)\n", []string{"data:", "href", "<script"}, nil},
159+ {"vbscript and file", "[a](vbscript:x) [b](file:///etc/passwd)\n", []string{"vbscript", "file:", "href"}, []string{"a b"}},
160+ {"relative links lose the link", "[a](../../etc) [b](/proj/blob/main/x) [c](#frag)\n", []string{"href"}, []string{"a b c"}},
161+ {"protocol relative", "[x](//evil.test/p)\n", []string{"href", "evil"}, nil},
162+ {"https is kept with ugc", "[x](https://example.org/p?q=1)\n", nil,
163+ []string{`href="https://example.org/p?q=1"`, `rel="nofollow noopener noreferrer ugc"`}},
164+ {"http is kept", "[x](http://example.org/)\n", nil, []string{`href="http://example.org/"`, "ugc"}},
165+ {"credentials are refused", "[x](https://user:pw@example.org/)\n", []string{"href", "pw"}, nil},
166+ {"mailto is kept", "[x](mailto:a@example.org)\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
167+ {"mailto with query is refused", "[x](mailto:a@example.org?cc=b@example.org)\n", []string{"href"}, nil},
168+ {"autolink url", "<https://example.org/a> and <javascript:alert(1)>\n", []string{"href=\"javascript"}, []string{`rel="nofollow noopener noreferrer ugc"`}},
169+ {"autolink email", "<a@example.org>\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
170+ {"bare url (GFM)", "see https://example.org/x now\n", nil, []string{`href="https://example.org/x"`, "ugc"}},
171+ {"image is its alt text", "![pwn](https://evil.test/p.png)\n", []string{"<img", "evil.test"}, []string{`<span class="alt">[pwn]</span>`}},
172+ {"reference link to javascript", "[x][r]\n\n[r]: javascript:alert(1)\n", []string{"javascript", "href"}, nil},
173+ {"entity-encoded scheme", "[x](&#106;avascript:alert(1))\n", []string{"avascript:", "href"}, nil},
174+ }
175+ for _, tt := range tests {
176+ t.Run(tt.name, func(t *testing.T) {
177+ t.Parallel()
178+ out := renderUntrustedMD(t, tt.src)
179+ for _, a := range tt.absent {
180+ assert.NotContains(t, out, a)
181+ }
182+ for _, p := range tt.present {
183+ assert.Contains(t, out, p)
184+ }
185+ })
186+ }
187+}
188+
189+func TestMarkdownUntrustedBigInputs(t *testing.T) {
190+ t.Parallel()
191+ long := strings.Repeat("a", 10000)
192+ out := renderUntrustedMD(t, long)
193+ assert.Contains(t, out, long)
194+
195+ rows := "| a | b |\n|---|---|\n" + strings.Repeat("| <b>x</b> | [l](javascript:1) |\n", 500)
196+ out = renderUntrustedMD(t, rows)
197+ assert.NotContains(t, out, "<b>")
198+ assert.NotContains(t, out, "javascript")
199+ assert.NotContains(t, out, "style=")
200+
201+ deep := strings.Repeat("> ", 200) + "[x](javascript:1)\n"
202+ assert.NotContains(t, renderUntrustedMD(t, deep), "javascript")
203+
204+ assert.NotContains(t, renderUntrustedMD(t, "[x](https://a.test/\"onmouseover=\"alert(1))\n"), "onmouseover=\"")
205+}
206+
140207 func TestNoteMarkdown(t *testing.T) {
141208 t.Parallel()
142209 tests := []struct {
@@ -137,6 +137,73 @@ func TestMarkdownGFM(t *testing.T) {
137 assert.Contains(t, out, `type="checkbox"`)137 assert.Contains(t, out, `type="checkbox"`)
138 }138 }
139 139
140+func renderUntrustedMD(t *testing.T, src string) string {
141+ t.Helper()
142+ h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-")
143+ require.NoError(t, err)
144+ return string(h)
145+}
146+
147+// An issue from the public form is rendered with the strictest policy.
148+func TestMarkdownUntrusted(t *testing.T) {
149+ t.Parallel()
150+ tests := []struct {
151+ name, src string
152+ absent []string
153+ present []string
154+ }{
155+ {"script", "<script>alert(1)</script>\n", []string{"<script", "alert(1)</"}, nil},
156+ {"img onerror", "a <img src=x onerror=alert(1)> b\n", []string{"<img", "onerror"}, []string{"<p>a "}},
157+ {"javascript link", "[x](javascript:alert(1))\n", []string{"javascript", "href"}, []string{"<p>x</p>"}},
158+ {"data link", "[x](data:text/html,<script>alert(1)</script>)\n", []string{"data:", "href", "<script"}, nil},
159+ {"vbscript and file", "[a](vbscript:x) [b](file:///etc/passwd)\n", []string{"vbscript", "file:", "href"}, []string{"a b"}},
160+ {"relative links lose the link", "[a](../../etc) [b](/proj/blob/main/x) [c](#frag)\n", []string{"href"}, []string{"a b c"}},
161+ {"protocol relative", "[x](//evil.test/p)\n", []string{"href", "evil"}, nil},
162+ {"https is kept with ugc", "[x](https://example.org/p?q=1)\n", nil,
163+ []string{`href="https://example.org/p?q=1"`, `rel="nofollow noopener noreferrer ugc"`}},
164+ {"http is kept", "[x](http://example.org/)\n", nil, []string{`href="http://example.org/"`, "ugc"}},
165+ {"credentials are refused", "[x](https://user:pw@example.org/)\n", []string{"href", "pw"}, nil},
166+ {"mailto is kept", "[x](mailto:a@example.org)\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
167+ {"mailto with query is refused", "[x](mailto:a@example.org?cc=b@example.org)\n", []string{"href"}, nil},
168+ {"autolink url", "<https://example.org/a> and <javascript:alert(1)>\n", []string{"href=\"javascript"}, []string{`rel="nofollow noopener noreferrer ugc"`}},
169+ {"autolink email", "<a@example.org>\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
170+ {"bare url (GFM)", "see https://example.org/x now\n", nil, []string{`href="https://example.org/x"`, "ugc"}},
171+ {"image is its alt text", "![pwn](https://evil.test/p.png)\n", []string{"<img", "evil.test"}, []string{`<span class="alt">[pwn]</span>`}},
172+ {"reference link to javascript", "[x][r]\n\n[r]: javascript:alert(1)\n", []string{"javascript", "href"}, nil},
173+ {"entity-encoded scheme", "[x](&#106;avascript:alert(1))\n", []string{"avascript:", "href"}, nil},
174+ }
175+ for _, tt := range tests {
176+ t.Run(tt.name, func(t *testing.T) {
177+ t.Parallel()
178+ out := renderUntrustedMD(t, tt.src)
179+ for _, a := range tt.absent {
180+ assert.NotContains(t, out, a)
181+ }
182+ for _, p := range tt.present {
183+ assert.Contains(t, out, p)
184+ }
185+ })
186+ }
187+}
188+
189+func TestMarkdownUntrustedBigInputs(t *testing.T) {
190+ t.Parallel()
191+ long := strings.Repeat("a", 10000)
192+ out := renderUntrustedMD(t, long)
193+ assert.Contains(t, out, long)
194+
195+ rows := "| a | b |\n|---|---|\n" + strings.Repeat("| <b>x</b> | [l](javascript:1) |\n", 500)
196+ out = renderUntrustedMD(t, rows)
197+ assert.NotContains(t, out, "<b>")
198+ assert.NotContains(t, out, "javascript")
199+ assert.NotContains(t, out, "style=")
200+
201+ deep := strings.Repeat("> ", 200) + "[x](javascript:1)\n"
202+ assert.NotContains(t, renderUntrustedMD(t, deep), "javascript")
203+
204+ assert.NotContains(t, renderUntrustedMD(t, "[x](https://a.test/\"onmouseover=\"alert(1))\n"), "onmouseover=\"")
205+}
206+
140 func TestNoteMarkdown(t *testing.T) {207 func TestNoteMarkdown(t *testing.T) {
141 t.Parallel()208 t.Parallel()
142 tests := []struct {209 tests := []struct {
M internal/adapters/web/mr.go +13 -8
@@ -101,12 +101,15 @@ type MRListPage struct {
101101
102102 // CommentView is a comment of a merge request, ready to show.
103103 type CommentView struct {
104- ID string
105- Author string
106- When string
107- WhenAt string
108- Abs string
109- Body, BodySplit template.HTML
104+ ID string
105+ Author string
106+ When string
107+ WhenAt string
108+ Abs string
109+ Body, BodySplit template.HTML
110+ // Parts is a plain-text body (an issue comment); Body is the Markdown of
111+ // a merge request note.
112+ Parts []TextPart
110113 Replies []CommentView
111114 Edited, Deleted bool
112115 Verdict, Range, Quote string
@@ -156,8 +159,10 @@ type CommentForm struct {
156159 // Path, Line and Side designate the line; empty for a general comment.
157160 Path, Line, Side string
158161 StartLine, ReplyTo string
159- Cancel string
160- Focus bool
162+ // Plain is a form of an issue comment: plain text, no verdict.
163+ Plain bool
164+ Cancel string
165+ Focus bool
161166 // From is the window of the page the form is on (see mrWindow), sent
162167 // back so that the answer returns to it; 0 for the first.
163168 From int
@@ -101,12 +101,15 @@ type MRListPage struct {
101 101
102 // CommentView is a comment of a merge request, ready to show.102 // CommentView is a comment of a merge request, ready to show.
103 type CommentView struct {103 type CommentView struct {
104- ID string104+ ID string
105- Author string105+ Author string
106- When string106+ When string
107- WhenAt string107+ WhenAt string
108- Abs string108+ Abs string
109- Body, BodySplit template.HTML109+ Body, BodySplit template.HTML
110+ // Parts is a plain-text body (an issue comment); Body is the Markdown of
111+ // a merge request note.
112+ Parts []TextPart
110 Replies []CommentView113 Replies []CommentView
111 Edited, Deleted bool114 Edited, Deleted bool
112 Verdict, Range, Quote string115 Verdict, Range, Quote string
@@ -156,8 +159,10 @@ type CommentForm struct {
156 // Path, Line and Side designate the line; empty for a general comment.159 // Path, Line and Side designate the line; empty for a general comment.
157 Path, Line, Side string160 Path, Line, Side string
158 StartLine, ReplyTo string161 StartLine, ReplyTo string
159- Cancel string162+ // Plain is a form of an issue comment: plain text, no verdict.
160- Focus bool163+ Plain bool
164+ Cancel string
165+ Focus bool
161 // From is the window of the page the form is on (see mrWindow), sent166 // From is the window of the page the form is on (see mrWindow), sent
162 // back so that the answer returns to it; 0 for the first.167 // back so that the answer returns to it; 0 for the first.
163 From int168 From int
M internal/adapters/web/mr.templ +10 -3
@@ -247,7 +247,9 @@ templ commentView(c CommentView, suffix string) {
247247 { " " }<span class="c-where mute">{ c.Where }</span>
248248 }
249249 </p>
250- if c.Deleted {
250+ if len(c.Parts) > 0 {
251+ <p class="c-text">@userText(c.Parts)</p>
252+ } else if c.Deleted {
251253 if c.Verdict != "" {
252254 <p class="mute">text deleted</p>
253255 } else {
@@ -316,7 +318,12 @@ templ commentForm(f CommentForm, suffix string) {
316318 name="text"
317319 rows="3"
318320 maxlength="8000"
319- placeholder="Markdown, 8000 characters at most"
321+ if f.Plain {
322+ required
323+ placeholder="plain text, 8000 characters at most"
324+ } else {
325+ placeholder="Markdown, 8000 characters at most"
326+ }
320327 if f.Focus {
321328 autofocus
322329 }
@@ -330,7 +337,7 @@ templ commentForm(f CommentForm, suffix string) {
330337 if f.ReplyTo != "" {
331338 <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
332339 }
333- if f.Path == "" && f.ReplyTo == "" {
340+ if f.Path == "" && f.ReplyTo == "" && !f.Plain {
334341 <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>
335342 }
336343 if f.From > 0 {
@@ -247,7 +247,9 @@ templ commentView(c CommentView, suffix string) {
247 { " " }<span class="c-where mute">{ c.Where }</span>247 { " " }<span class="c-where mute">{ c.Where }</span>
248 }248 }
249 </p>249 </p>
250- if c.Deleted {250+ if len(c.Parts) > 0 {
251+ <p class="c-text">@userText(c.Parts)</p>
252+ } else if c.Deleted {
251 if c.Verdict != "" {253 if c.Verdict != "" {
252 <p class="mute">text deleted</p>254 <p class="mute">text deleted</p>
253 } else {255 } else {
@@ -316,7 +318,12 @@ templ commentForm(f CommentForm, suffix string) {
316 name="text"318 name="text"
317 rows="3"319 rows="3"
318 maxlength="8000"320 maxlength="8000"
319- placeholder="Markdown, 8000 characters at most"321+ if f.Plain {
322+ required
323+ placeholder="plain text, 8000 characters at most"
324+ } else {
325+ placeholder="Markdown, 8000 characters at most"
326+ }
320 if f.Focus {327 if f.Focus {
321 autofocus328 autofocus
322 }329 }
@@ -330,7 +337,7 @@ templ commentForm(f CommentForm, suffix string) {
330 if f.ReplyTo != "" {337 if f.ReplyTo != "" {
331 <input type="hidden" name="reply_to" value={ f.ReplyTo }/>338 <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
332 }339 }
333- if f.Path == "" && f.ReplyTo == "" {340+ if f.Path == "" && f.ReplyTo == "" && !f.Plain {
334 <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>341 <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>
335 }342 }
336 if f.From > 0 {343 if f.From > 0 {
M internal/adapters/web/mr_post.go +8 -1
@@ -31,7 +31,14 @@ const maxFormBytes = 128 << 10
3131 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
3232 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
3333 if err := r.ParseForm(); err != nil {
34- s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than a merge request can be.", mrBase(r.PathValue("repo")))
34+ back, what := mrBase(r.PathValue("repo")), "a merge request"
35+ if strings.HasPrefix(r.URL.Path, inboxPath+"/") {
36+ back, what = inboxPath, "a moderation action"
37+ }
38+ if strings.Contains(r.URL.Path, "/issues") {
39+ back, what = issuesBase(r.PathValue("repo")), "an issue"
40+ }
41+ s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than "+what+" can be.", back)
3542 return false
3643 }
3744 return true
@@ -31,7 +31,14 @@ const maxFormBytes = 128 << 10
31 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {31 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
32 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)32 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
33 if err := r.ParseForm(); err != nil {33 if err := r.ParseForm(); err != nil {
34- s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than a merge request can be.", mrBase(r.PathValue("repo")))34+ back, what := mrBase(r.PathValue("repo")), "a merge request"
35+ if strings.HasPrefix(r.URL.Path, inboxPath+"/") {
36+ back, what = inboxPath, "a moderation action"
37+ }
38+ if strings.Contains(r.URL.Path, "/issues") {
39+ back, what = issuesBase(r.PathValue("repo")), "an issue"
40+ }
41+ s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than "+what+" can be.", back)
35 return false42 return false
36 }43 }
37 return true44 return true
M internal/adapters/web/mr_templ.go +289 -265 generated file: collapsed
M internal/adapters/web/overlays.templ +1 -1
@@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) {
2626 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
2727 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
2828 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
29- <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>
29+ <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
3030 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
3131 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
3232 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
@@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) {
26 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>26 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
27 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>27 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
29- <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>29+ <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
31 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>31 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
32 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>32 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
M internal/adapters/web/overlays_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/pages.templ +8 -0
@@ -9,6 +9,14 @@ templ Home(l Layout, p HomePage) {
99 <div class="home" data-layout="cards">
1010 <section class="home-repos" aria-label="Repositories">
1111 <div class="home-bar">
12+ if p.Inbox != nil {
13+ <a class="btn inbox-btn" href={ templ.SafeURL(p.Inbox.Href) } title="inbox">
14+ Inbox
15+ if p.Inbox.Count >= 0 {
16+ <span class="count">{ itoa(p.Inbox.Count) }</span>
17+ }
18+ </a>
19+ }
1220 <div class="filter">
1321 <label for="home-filter" class="sr">filter</label>
1422 <input
@@ -9,6 +9,14 @@ templ Home(l Layout, p HomePage) {
9 <div class="home" data-layout="cards">9 <div class="home" data-layout="cards">
10 <section class="home-repos" aria-label="Repositories">10 <section class="home-repos" aria-label="Repositories">
11 <div class="home-bar">11 <div class="home-bar">
12+ if p.Inbox != nil {
13+ <a class="btn inbox-btn" href={ templ.SafeURL(p.Inbox.Href) } title="inbox">
14+ Inbox
15+ if p.Inbox.Count >= 0 {
16+ <span class="count">{ itoa(p.Inbox.Count) }</span>
17+ }
18+ </a>
19+ }
12 <div class="filter">20 <div class="filter">
13 <label for="home-filter" class="sr">filter</label>21 <label for="home-filter" class="sr">filter</label>
14 <input22 <input
M internal/adapters/web/pages_templ.go +409 -363 generated file: collapsed
M internal/adapters/web/repo.go +5 -0
@@ -131,6 +131,8 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
131131 // layout is the Layout of the pages of a repository: the sections, the ref
132132 // switcher on the head ref and the ":" targets. The caller sets the title,
133133 // the crumbs, the status and the rail.
134+//
135+//hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here
134136 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
135137 l := s.base()
136138 l.Repo = d.name()
@@ -155,6 +157,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
155157 mrs.Count = s.openMRCount(ctx, d.repo.Name)
156158 l.Sections = append(l.Sections, mrs)
157159 }
160+ if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {
161+ l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
162+ }
158163 l.Sections = s.withActions(l.Sections, d.name(), section)
159164 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
160165
@@ -131,6 +131,8 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
131 // layout is the Layout of the pages of a repository: the sections, the ref131 // layout is the Layout of the pages of a repository: the sections, the ref
132 // switcher on the head ref and the ":" targets. The caller sets the title,132 // switcher on the head ref and the ":" targets. The caller sets the title,
133 // the crumbs, the status and the rail.133 // the crumbs, the status and the rail.
134+//
135+//hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here
134 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {136 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
135 l := s.base()137 l := s.base()
136 l.Repo = d.name()138 l.Repo = d.name()
@@ -155,6 +157,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
155 mrs.Count = s.openMRCount(ctx, d.repo.Name)157 mrs.Count = s.openMRCount(ctx, d.repo.Name)
156 l.Sections = append(l.Sections, mrs)158 l.Sections = append(l.Sections, mrs)
157 }159 }
160+ if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {
161+ l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
162+ }
158 l.Sections = s.withActions(l.Sections, d.name(), section)163 l.Sections = s.withActions(l.Sections, d.name(), section)
159 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}164 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
160 165
M internal/adapters/web/session.go +16 -1
@@ -196,7 +196,22 @@ func (s *Server) anonymousPost(r *http.Request) bool {
196196 if r.Method != http.MethodPost {
197197 return false
198198 }
199- return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))
199+ return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath())) ||
200+ (s.cfg.IssueForm != nil && isIssueFormPath(r.URL))
201+}
202+
203+// isIssueFormPath tells whether u is exactly /<repo>/issues/new for a valid
204+// repository name: the one write a visitor may make besides signing in. It
205+// looks at the escaped path, which is what the router matches, so that an
206+// encoded slash or a trailing segment never passes for it. The Origin check of
207+// the gate still applies.
208+func isIssueFormPath(u *url.URL) bool {
209+ repo, ok := strings.CutSuffix(strings.TrimPrefix(u.EscapedPath(), "/"), "/issues/new")
210+ if !ok || strings.Contains(repo, "/") || strings.HasSuffix(repo, ".git") {
211+ return false
212+ }
213+ _, err := domain.ParseRepoName(repo)
214+ return err == nil
200215 }
201216
202217 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
@@ -196,7 +196,22 @@ func (s *Server) anonymousPost(r *http.Request) bool {
196 if r.Method != http.MethodPost {196 if r.Method != http.MethodPost {
197 return false197 return false
198 }198 }
199- return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))199+ return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath())) ||
200+ (s.cfg.IssueForm != nil && isIssueFormPath(r.URL))
201+}
202+
203+// isIssueFormPath tells whether u is exactly /<repo>/issues/new for a valid
204+// repository name: the one write a visitor may make besides signing in. It
205+// looks at the escaped path, which is what the router matches, so that an
206+// encoded slash or a trailing segment never passes for it. The Origin check of
207+// the gate still applies.
208+func isIssueFormPath(u *url.URL) bool {
209+ repo, ok := strings.CutSuffix(strings.TrimPrefix(u.EscapedPath(), "/"), "/issues/new")
210+ if !ok || strings.Contains(repo, "/") || strings.HasSuffix(repo, ".git") {
211+ return false
212+ }
213+ _, err := domain.ParseRepoName(repo)
214+ return err == nil
200 }215 }
201 216
202 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of217 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
M internal/adapters/web/shell.templ +3 -0
@@ -33,6 +33,9 @@ templ Shell(l Layout, rail templ.Component) {
3333 if l.MR {
3434 <script src={ l.Assets.MR } defer></script>
3535 }
36+ if l.IssueForm {
37+ <script src={ l.Assets.IssueForm } defer></script>
38+ }
3639 </head>
3740 <body
3841 data-ref={ l.RefPath }
@@ -33,6 +33,9 @@ templ Shell(l Layout, rail templ.Component) {
33 if l.MR {33 if l.MR {
34 <script src={ l.Assets.MR } defer></script>34 <script src={ l.Assets.MR } defer></script>
35 }35 }
36+ if l.IssueForm {
37+ <script src={ l.Assets.IssueForm } defer></script>
38+ }
36 </head>39 </head>
37 <body40 <body
38 data-ref={ l.RefPath }41 data-ref={ l.RefPath }
M internal/adapters/web/shell_templ.go +180 -161 generated file: collapsed
M internal/adapters/web/static/css/60-mr.css +25 -0
@@ -171,6 +171,22 @@
171171 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }
172172 }
173173
174+ /* the public issue form: counters, field errors, the honeypot out of sight */
175+ .cform output { justify-self: end; font: var(--fs-s) var(--mono); color: var(--ink-mute); }
176+ .cform output.warn { color: var(--ink-hi); font-weight: 700; }
177+ .cform output.over { color: var(--red); font-weight: 700; }
178+ .cform .f-error { color: var(--red); margin: 0; }
179+ .cform .hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
180+
181+ /* the inbox: items awaiting moderation, shown as text */
182+ .pending-list { display: grid; gap: var(--s3); list-style: none; padding: 0; margin: 0; }
183+ .pending { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); display: grid; gap: var(--s2); }
184+ .pending .p-head { display: flex; gap: var(--s3); flex-wrap: wrap; align-items: baseline; color: var(--ink-dim); font-size: var(--fs-s); }
185+ .pending .p-title { margin: 0; font-weight: 700; color: var(--ink-hi); overflow-wrap: anywhere; }
186+ .pending .p-body { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
187+ .pending .p-btns { display: flex; gap: var(--s2); flex-wrap: wrap; }
188+ .pending .p-btns form { margin: 0; }
189+
174190 .list.mrs {
175191 & .c-n { width: 64px; }
176192 & .c-state { width: 110px; }
@@ -184,6 +200,15 @@
184200 }
185201 #mr-notice:empty { display: none; }
186202
203+ /* the owner's inbox: counters, repository filter, then the two lists */
204+ .inbox {
205+ display: flex; flex-direction: column; gap: var(--s4);
206+ & .inbox-counts { font-size: var(--fs-l, var(--fs)); color: var(--ink-dim); }
207+ & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); }
208+ & .since { align-self: flex-start; max-width: 100%; }
209+ }
210+ .inbox-btn { gap: var(--s2); }
211+
187212 @media (max-width: 700px) {
188213 .list.mrs .c-who { display: none; }
189214 }
@@ -171,6 +171,22 @@
171 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }171 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }
172 }172 }
173 173
174+ /* the public issue form: counters, field errors, the honeypot out of sight */
175+ .cform output { justify-self: end; font: var(--fs-s) var(--mono); color: var(--ink-mute); }
176+ .cform output.warn { color: var(--ink-hi); font-weight: 700; }
177+ .cform output.over { color: var(--red); font-weight: 700; }
178+ .cform .f-error { color: var(--red); margin: 0; }
179+ .cform .hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
180+
181+ /* the inbox: items awaiting moderation, shown as text */
182+ .pending-list { display: grid; gap: var(--s3); list-style: none; padding: 0; margin: 0; }
183+ .pending { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); display: grid; gap: var(--s2); }
184+ .pending .p-head { display: flex; gap: var(--s3); flex-wrap: wrap; align-items: baseline; color: var(--ink-dim); font-size: var(--fs-s); }
185+ .pending .p-title { margin: 0; font-weight: 700; color: var(--ink-hi); overflow-wrap: anywhere; }
186+ .pending .p-body { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
187+ .pending .p-btns { display: flex; gap: var(--s2); flex-wrap: wrap; }
188+ .pending .p-btns form { margin: 0; }
189+
174 .list.mrs {190 .list.mrs {
175 & .c-n { width: 64px; }191 & .c-n { width: 64px; }
176 & .c-state { width: 110px; }192 & .c-state { width: 110px; }
@@ -184,6 +200,15 @@
184 }200 }
185 #mr-notice:empty { display: none; }201 #mr-notice:empty { display: none; }
186 202
203+ /* the owner's inbox: counters, repository filter, then the two lists */
204+ .inbox {
205+ display: flex; flex-direction: column; gap: var(--s4);
206+ & .inbox-counts { font-size: var(--fs-l, var(--fs)); color: var(--ink-dim); }
207+ & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); }
208+ & .since { align-self: flex-start; max-width: 100%; }
209+ }
210+ .inbox-btn { gap: var(--s2); }
211+
187 @media (max-width: 700px) {212 @media (max-width: 700px) {
188 .list.mrs .c-who { display: none; }213 .list.mrs .c-who { display: none; }
189 }214 }
M internal/adapters/web/static/js/ermite.js +2 -2
@@ -555,11 +555,11 @@
555555
556556 // 1. Keys.
557557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
558- s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
558+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
559559 // the merge request page (mr.js): the merge menu, mark read
560560 M: 'merge', R: 'read' };
561561 // "g t" is the key "t": the tabs have one event each.
562- const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top', f: 'gf' };
562+ const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', g: 'top', f: 'gf' };
563563 let pending = 0;
564564 let pendingTimer = 0;
565565 addEventListener('keydown', (e) => {
@@ -555,11 +555,11 @@
555 555
556 // 1. Keys.556 // 1. Keys.
557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
558- s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',558+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
559 // the merge request page (mr.js): the merge menu, mark read559 // the merge request page (mr.js): the merge menu, mark read
560 M: 'merge', R: 'read' };560 M: 'merge', R: 'read' };
561 // "g t" is the key "t": the tabs have one event each.561 // "g t" is the key "t": the tabs have one event each.
562- const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top', f: 'gf' };562+ const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', g: 'top', f: 'gf' };
563 let pending = 0;563 let pending = 0;
564 let pendingTimer = 0;564 let pendingTimer = 0;
565 addEventListener('keydown', (e) => {565 addEventListener('keydown', (e) => {
A internal/adapters/web/static/js/issue-form.js +175 -0
@@ -0,0 +1,175 @@
1+// issue-form.js: the public issue form (/<repo>/issues/new for a visitor).
2+// The server sends a proof of work with the form; this script starts the
3+// worker (pow-worker.js) when the page loads, so it solves while the visitor
4+// writes, shows the length counters, and enables the button once the solution
5+// is found and the minimum fill time has passed. Without scripts the form
6+// stays hidden (the page says why).
7+//
8+// window.ermiteIssueForm.core is pure (jstest/issue-form.test.mjs).
9+window.ermiteIssueForm = (() => {
10+ const core = {
11+ // counterClass is the class of a length counter: "over" above max, "warn"
12+ // from 90 % of it, else none.
13+ counterClass(n, max) {
14+ if (n > max) return 'over';
15+ if (n * 10 >= max * 9) return 'warn';
16+ return '';
17+ },
18+ // length counts code points, like the server.
19+ length(s) { return [...String(s)].length; },
20+ // serverLength is the length the server will see for field ('title',
21+ // 'body' or 'author'): NFC, control and bidi characters removed (body
22+ // keeps \n and \t), title and author trimmed (domain.cleanText).
23+ serverLength(field, value) {
24+ const multiline = field === 'body';
25+ let s = String(value).normalize('NFC');
26+ if (multiline) s = s.replace(/\r\n/g, '\n');
27+ let out = '';
28+ for (const ch of s) {
29+ const c = ch.codePointAt(0);
30+ if (multiline && (c === 0x0a || c === 0x09)) { out += ch; continue; }
31+ if (c < 0x20 || (c >= 0x7f && c <= 0x9f) || c === 0x2028 || c === 0x2029) continue;
32+ if (c === 0x200e || c === 0x200f || c === 0x061c ||
33+ (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)) continue;
34+ out += ch;
35+ }
36+ if (!multiline) out = out.trim();
37+ return [...out].length;
38+ },
39+ draftKey(path) { return 'ermite:issue-draft:' + path; },
40+ // saveDraft keeps the text fields in storage (never the solution).
41+ saveDraft(storage, key, draft) {
42+ try { storage.setItem(key, JSON.stringify({ title: draft.title, body: draft.body, author: draft.author })); } catch (_) { /* storage unavailable */ }
43+ },
44+ // restoreDraft returns the saved draft, or null, and clears it.
45+ restoreDraft(storage, key) {
46+ try {
47+ const raw = storage.getItem(key);
48+ if (raw === null || raw === undefined) return null;
49+ storage.removeItem(key);
50+ const d = JSON.parse(raw);
51+ if (!d || typeof d !== 'object') return null;
52+ return { title: String(d.title || ''), body: String(d.body || ''), author: String(d.author || '') };
53+ } catch (_) { return null; }
54+ },
55+ // onPageShow decides what to do on pageshow: a page restored from the
56+ // back/forward cache has a challenge that may be spent or expired.
57+ onPageShow(persisted) { return persisted === true ? 'reload' : 'none'; },
58+ // state is what the button and the status line show.
59+ // {solved, now, minAt, sending, unsupported, failed, overs, empty}
60+ state({ solved, now, minAt, sending, unsupported, failed, overs, empty }) {
61+ if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' };
62+ if (failed) return { disabled: true, status: 'The check failed. Reload the page.' };
63+ if (sending) return { disabled: true, status: 'Sending…' };
64+ if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' };
65+ if (now < minAt) return { disabled: true, status: 'Almost ready…' };
66+ if (overs) return { disabled: true, status: 'A field is too long.' };
67+ if (empty) return { disabled: true, status: 'Enter a title.' };
68+ return { disabled: false, status: 'Ready.' };
69+ },
70+ };
71+
72+ function init(form) {
73+ const $ = (sel) => form.querySelector(sel);
74+ const button = $('button[type="submit"]');
75+ const status = $('[data-status]');
76+ const solution = $('input[name="solution"]');
77+ const minAt = Number(form.dataset.minSubmitAt) || 0;
78+ const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false, empty: true };
79+ let timer = null;
80+
81+ function render() {
82+ const s = core.state({ ...ctx, now: Date.now(), minAt });
83+ button.disabled = s.disabled;
84+ if (status) status.textContent = s.status;
85+ clearTimeout(timer);
86+ if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20);
87+ }
88+
89+ const storage = (() => { try { return window.sessionStorage; } catch (_) { return null; } })();
90+ const draftKey = core.draftKey(window.location ? window.location.pathname : '');
91+ const fields = { title: $('[name="title"]'), body: $('[name="body"]'), author: $('[name="author"]') };
92+ if (storage) {
93+ const d = core.restoreDraft(storage, draftKey);
94+ if (d) for (const k of Object.keys(fields)) if (fields[k]) fields[k].value = d[k];
95+ }
96+ window.addEventListener('pageshow', (e) => {
97+ if (core.onPageShow(e.persisted) !== 'reload') return;
98+ // The old challenge may be spent or expired: never reuse its solution.
99+ if (button) button.disabled = true;
100+ if (storage) {
101+ core.saveDraft(storage, draftKey, {
102+ title: fields.title ? fields.title.value : '',
103+ body: fields.body ? fields.body.value : '',
104+ author: fields.author ? fields.author.value : '',
105+ });
106+ }
107+ window.location.reload();
108+ });
109+
110+ const outs = [...form.querySelectorAll('output[data-for]')];
111+ for (const out of outs) {
112+ const field = form.querySelector('#' + out.dataset.for);
113+ const name = field.name;
114+ const max = Number(out.dataset.max);
115+ const update = () => {
116+ const n = core.serverLength(name, field.value);
117+ out.textContent = n + ' / ' + max;
118+ out.className = core.counterClass(n, max);
119+ ctx.overs = outs.some((o) => o.className === 'over');
120+ if (name === 'title') ctx.empty = n === 0;
121+ render();
122+ };
123+ field.addEventListener('input', update);
124+ update();
125+ }
126+
127+ form.addEventListener('submit', (e) => {
128+ if (ctx.sending || !ctx.solved || Date.now() < minAt) { e.preventDefault(); return; }
129+ ctx.sending = true;
130+ render();
131+ });
132+
133+ form.hidden = false;
134+ form.removeAttribute('hidden');
135+
136+ if (!window.Worker || !window.crypto || !window.crypto.subtle || !form.dataset.worker) {
137+ ctx.unsupported = true;
138+ render();
139+ return;
140+ }
141+ render();
142+ let worker;
143+ try {
144+ worker = new window.Worker(form.dataset.worker);
145+ } catch (_) {
146+ ctx.unsupported = true;
147+ render();
148+ return;
149+ }
150+ worker.onmessage = (e) => {
151+ const m = e.data || {};
152+ if (m.solution !== undefined) {
153+ solution.value = m.solution;
154+ ctx.solved = true;
155+ worker.terminate();
156+ } else if (m.error === 'nocrypto') {
157+ ctx.unsupported = true;
158+ } else if (m.error) {
159+ ctx.failed = true;
160+ } else {
161+ return;
162+ }
163+ render();
164+ };
165+ worker.onerror = () => { ctx.failed = true; render(); };
166+ worker.postMessage({ nonce: form.dataset.nonce, difficulty: Number(form.dataset.difficulty) });
167+ }
168+
169+ return { core, init };
170+})();
171+
172+document.addEventListener('DOMContentLoaded', () => {
173+ const form = document.querySelector('form[data-issue-form]');
174+ if (form) window.ermiteIssueForm.init(form);
175+});
@@ -0,0 +1,175 @@
1+// issue-form.js: the public issue form (/<repo>/issues/new for a visitor).
2+// The server sends a proof of work with the form; this script starts the
3+// worker (pow-worker.js) when the page loads, so it solves while the visitor
4+// writes, shows the length counters, and enables the button once the solution
5+// is found and the minimum fill time has passed. Without scripts the form
6+// stays hidden (the page says why).
7+//
8+// window.ermiteIssueForm.core is pure (jstest/issue-form.test.mjs).
9+window.ermiteIssueForm = (() => {
10+ const core = {
11+ // counterClass is the class of a length counter: "over" above max, "warn"
12+ // from 90 % of it, else none.
13+ counterClass(n, max) {
14+ if (n > max) return 'over';
15+ if (n * 10 >= max * 9) return 'warn';
16+ return '';
17+ },
18+ // length counts code points, like the server.
19+ length(s) { return [...String(s)].length; },
20+ // serverLength is the length the server will see for field ('title',
21+ // 'body' or 'author'): NFC, control and bidi characters removed (body
22+ // keeps \n and \t), title and author trimmed (domain.cleanText).
23+ serverLength(field, value) {
24+ const multiline = field === 'body';
25+ let s = String(value).normalize('NFC');
26+ if (multiline) s = s.replace(/\r\n/g, '\n');
27+ let out = '';
28+ for (const ch of s) {
29+ const c = ch.codePointAt(0);
30+ if (multiline && (c === 0x0a || c === 0x09)) { out += ch; continue; }
31+ if (c < 0x20 || (c >= 0x7f && c <= 0x9f) || c === 0x2028 || c === 0x2029) continue;
32+ if (c === 0x200e || c === 0x200f || c === 0x061c ||
33+ (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)) continue;
34+ out += ch;
35+ }
36+ if (!multiline) out = out.trim();
37+ return [...out].length;
38+ },
39+ draftKey(path) { return 'ermite:issue-draft:' + path; },
40+ // saveDraft keeps the text fields in storage (never the solution).
41+ saveDraft(storage, key, draft) {
42+ try { storage.setItem(key, JSON.stringify({ title: draft.title, body: draft.body, author: draft.author })); } catch (_) { /* storage unavailable */ }
43+ },
44+ // restoreDraft returns the saved draft, or null, and clears it.
45+ restoreDraft(storage, key) {
46+ try {
47+ const raw = storage.getItem(key);
48+ if (raw === null || raw === undefined) return null;
49+ storage.removeItem(key);
50+ const d = JSON.parse(raw);
51+ if (!d || typeof d !== 'object') return null;
52+ return { title: String(d.title || ''), body: String(d.body || ''), author: String(d.author || '') };
53+ } catch (_) { return null; }
54+ },
55+ // onPageShow decides what to do on pageshow: a page restored from the
56+ // back/forward cache has a challenge that may be spent or expired.
57+ onPageShow(persisted) { return persisted === true ? 'reload' : 'none'; },
58+ // state is what the button and the status line show.
59+ // {solved, now, minAt, sending, unsupported, failed, overs, empty}
60+ state({ solved, now, minAt, sending, unsupported, failed, overs, empty }) {
61+ if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' };
62+ if (failed) return { disabled: true, status: 'The check failed. Reload the page.' };
63+ if (sending) return { disabled: true, status: 'Sending…' };
64+ if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' };
65+ if (now < minAt) return { disabled: true, status: 'Almost ready…' };
66+ if (overs) return { disabled: true, status: 'A field is too long.' };
67+ if (empty) return { disabled: true, status: 'Enter a title.' };
68+ return { disabled: false, status: 'Ready.' };
69+ },
70+ };
71+
72+ function init(form) {
73+ const $ = (sel) => form.querySelector(sel);
74+ const button = $('button[type="submit"]');
75+ const status = $('[data-status]');
76+ const solution = $('input[name="solution"]');
77+ const minAt = Number(form.dataset.minSubmitAt) || 0;
78+ const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false, empty: true };
79+ let timer = null;
80+
81+ function render() {
82+ const s = core.state({ ...ctx, now: Date.now(), minAt });
83+ button.disabled = s.disabled;
84+ if (status) status.textContent = s.status;
85+ clearTimeout(timer);
86+ if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20);
87+ }
88+
89+ const storage = (() => { try { return window.sessionStorage; } catch (_) { return null; } })();
90+ const draftKey = core.draftKey(window.location ? window.location.pathname : '');
91+ const fields = { title: $('[name="title"]'), body: $('[name="body"]'), author: $('[name="author"]') };
92+ if (storage) {
93+ const d = core.restoreDraft(storage, draftKey);
94+ if (d) for (const k of Object.keys(fields)) if (fields[k]) fields[k].value = d[k];
95+ }
96+ window.addEventListener('pageshow', (e) => {
97+ if (core.onPageShow(e.persisted) !== 'reload') return;
98+ // The old challenge may be spent or expired: never reuse its solution.
99+ if (button) button.disabled = true;
100+ if (storage) {
101+ core.saveDraft(storage, draftKey, {
102+ title: fields.title ? fields.title.value : '',
103+ body: fields.body ? fields.body.value : '',
104+ author: fields.author ? fields.author.value : '',
105+ });
106+ }
107+ window.location.reload();
108+ });
109+
110+ const outs = [...form.querySelectorAll('output[data-for]')];
111+ for (const out of outs) {
112+ const field = form.querySelector('#' + out.dataset.for);
113+ const name = field.name;
114+ const max = Number(out.dataset.max);
115+ const update = () => {
116+ const n = core.serverLength(name, field.value);
117+ out.textContent = n + ' / ' + max;
118+ out.className = core.counterClass(n, max);
119+ ctx.overs = outs.some((o) => o.className === 'over');
120+ if (name === 'title') ctx.empty = n === 0;
121+ render();
122+ };
123+ field.addEventListener('input', update);
124+ update();
125+ }
126+
127+ form.addEventListener('submit', (e) => {
128+ if (ctx.sending || !ctx.solved || Date.now() < minAt) { e.preventDefault(); return; }
129+ ctx.sending = true;
130+ render();
131+ });
132+
133+ form.hidden = false;
134+ form.removeAttribute('hidden');
135+
136+ if (!window.Worker || !window.crypto || !window.crypto.subtle || !form.dataset.worker) {
137+ ctx.unsupported = true;
138+ render();
139+ return;
140+ }
141+ render();
142+ let worker;
143+ try {
144+ worker = new window.Worker(form.dataset.worker);
145+ } catch (_) {
146+ ctx.unsupported = true;
147+ render();
148+ return;
149+ }
150+ worker.onmessage = (e) => {
151+ const m = e.data || {};
152+ if (m.solution !== undefined) {
153+ solution.value = m.solution;
154+ ctx.solved = true;
155+ worker.terminate();
156+ } else if (m.error === 'nocrypto') {
157+ ctx.unsupported = true;
158+ } else if (m.error) {
159+ ctx.failed = true;
160+ } else {
161+ return;
162+ }
163+ render();
164+ };
165+ worker.onerror = () => { ctx.failed = true; render(); };
166+ worker.postMessage({ nonce: form.dataset.nonce, difficulty: Number(form.dataset.difficulty) });
167+ }
168+
169+ return { core, init };
170+})();
171+
172+document.addEventListener('DOMContentLoaded', () => {
173+ const form = document.querySelector('form[data-issue-form]');
174+ if (form) window.ermiteIssueForm.init(form);
175+});
A internal/adapters/web/static/js/pow-worker.js +72 -0
@@ -0,0 +1,72 @@
1+// pow-worker.js: the proof of work of the public issue form, in a dedicated
2+// Worker (started by issue-form.js). It finds the smallest decimal counter n
3+// such that SHA-256(nonce bytes || ":" || n) has at least `difficulty` leading
4+// zero bits: the very check of the server (usecase/issue_submissions.go,
5+// powHash). The nonce arrives as base64url without padding, like the server
6+// sends it. Messages in: {nonce, difficulty}. Messages out:
7+// {progress: tried} now and then, then {solution: "<decimal>"} or {error}.
8+//
9+// window-less on purpose: ermitePow is also loaded by Node for the tests
10+// (jstest/issue-form.test.mjs).
11+(() => {
12+ const BATCH = 2048;
13+ const enc = new TextEncoder();
14+
15+ // leadingZeroBits counts the zero bits at the start of the bytes.
16+ function leadingZeroBits(bytes) {
17+ let n = 0;
18+ for (const b of bytes) {
19+ if (b === 0) { n += 8; continue; }
20+ return n + Math.clz32(b) - 24;
21+ }
22+ return n;
23+ }
24+
25+ // b64urlDecode turns unpadded base64url into bytes.
26+ function b64urlDecode(s) {
27+ const bin = atob(String(s).replace(/-/g, '+').replace(/_/g, '/'));
28+ const out = new Uint8Array(bin.length);
29+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
30+ return out;
31+ }
32+
33+ // input is nonce || ":" || decimal(n), the bytes the server hashes.
34+ function input(nonce, n) {
35+ const tail = enc.encode(':' + n);
36+ const out = new Uint8Array(nonce.length + tail.length);
37+ out.set(nonce, 0);
38+ out.set(tail, nonce.length);
39+ return out;
40+ }
41+
42+ // solve returns the smallest solution as a decimal string. digest is
43+ // (Uint8Array) => Promise<ArrayBuffer-like>, crypto.subtle.digest by default.
44+ // Hashes are asked a batch at a time, in order, so the first hit of the first
45+ // batch that has one is the smallest. onProgress(tried) is called after each
46+ // batch; it may return true to stop (solve then returns null).
47+ async function solve(nonce, difficulty, { digest, batch = BATCH, onProgress } = {}) {
48+ const dig = digest || ((d) => globalThis.crypto.subtle.digest('SHA-256', d));
49+ for (let start = 0; ; start += batch) {
50+ const sums = await Promise.all(Array.from({ length: batch }, async (_, i) => new Uint8Array(await dig(input(nonce, start + i)))));
51+ for (let i = 0; i < batch; i++) {
52+ if (leadingZeroBits(sums[i]) >= difficulty) return String(start + i);
53+ }
54+ if (onProgress && onProgress(start + batch)) return null;
55+ }
56+ }
57+
58+ globalThis.ermitePow = { leadingZeroBits, b64urlDecode, input, solve };
59+
60+ if (typeof WorkerGlobalScope !== 'undefined' && typeof globalThis.postMessage === 'function') {
61+ globalThis.onmessage = async (e) => {
62+ try {
63+ if (!globalThis.crypto || !globalThis.crypto.subtle) { postMessage({ error: 'nocrypto' }); return; }
64+ const { nonce, difficulty } = e.data;
65+ const solution = await solve(b64urlDecode(nonce), difficulty, { onProgress: (tried) => { postMessage({ progress: tried }); } });
66+ postMessage({ solution });
67+ } catch (err) {
68+ postMessage({ error: String(err && err.message || err) });
69+ }
70+ };
71+ }
72+})();
@@ -0,0 +1,72 @@
1+// pow-worker.js: the proof of work of the public issue form, in a dedicated
2+// Worker (started by issue-form.js). It finds the smallest decimal counter n
3+// such that SHA-256(nonce bytes || ":" || n) has at least `difficulty` leading
4+// zero bits: the very check of the server (usecase/issue_submissions.go,
5+// powHash). The nonce arrives as base64url without padding, like the server
6+// sends it. Messages in: {nonce, difficulty}. Messages out:
7+// {progress: tried} now and then, then {solution: "<decimal>"} or {error}.
8+//
9+// window-less on purpose: ermitePow is also loaded by Node for the tests
10+// (jstest/issue-form.test.mjs).
11+(() => {
12+ const BATCH = 2048;
13+ const enc = new TextEncoder();
14+
15+ // leadingZeroBits counts the zero bits at the start of the bytes.
16+ function leadingZeroBits(bytes) {
17+ let n = 0;
18+ for (const b of bytes) {
19+ if (b === 0) { n += 8; continue; }
20+ return n + Math.clz32(b) - 24;
21+ }
22+ return n;
23+ }
24+
25+ // b64urlDecode turns unpadded base64url into bytes.
26+ function b64urlDecode(s) {
27+ const bin = atob(String(s).replace(/-/g, '+').replace(/_/g, '/'));
28+ const out = new Uint8Array(bin.length);
29+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
30+ return out;
31+ }
32+
33+ // input is nonce || ":" || decimal(n), the bytes the server hashes.
34+ function input(nonce, n) {
35+ const tail = enc.encode(':' + n);
36+ const out = new Uint8Array(nonce.length + tail.length);
37+ out.set(nonce, 0);
38+ out.set(tail, nonce.length);
39+ return out;
40+ }
41+
42+ // solve returns the smallest solution as a decimal string. digest is
43+ // (Uint8Array) => Promise<ArrayBuffer-like>, crypto.subtle.digest by default.
44+ // Hashes are asked a batch at a time, in order, so the first hit of the first
45+ // batch that has one is the smallest. onProgress(tried) is called after each
46+ // batch; it may return true to stop (solve then returns null).
47+ async function solve(nonce, difficulty, { digest, batch = BATCH, onProgress } = {}) {
48+ const dig = digest || ((d) => globalThis.crypto.subtle.digest('SHA-256', d));
49+ for (let start = 0; ; start += batch) {
50+ const sums = await Promise.all(Array.from({ length: batch }, async (_, i) => new Uint8Array(await dig(input(nonce, start + i)))));
51+ for (let i = 0; i < batch; i++) {
52+ if (leadingZeroBits(sums[i]) >= difficulty) return String(start + i);
53+ }
54+ if (onProgress && onProgress(start + batch)) return null;
55+ }
56+ }
57+
58+ globalThis.ermitePow = { leadingZeroBits, b64urlDecode, input, solve };
59+
60+ if (typeof WorkerGlobalScope !== 'undefined' && typeof globalThis.postMessage === 'function') {
61+ globalThis.onmessage = async (e) => {
62+ try {
63+ if (!globalThis.crypto || !globalThis.crypto.subtle) { postMessage({ error: 'nocrypto' }); return; }
64+ const { nonce, difficulty } = e.data;
65+ const solution = await solve(b64urlDecode(nonce), difficulty, { onProgress: (tried) => { postMessage({ progress: tried }); } });
66+ postMessage({ solution });
67+ } catch (err) {
68+ postMessage({ error: String(err && err.message || err) });
69+ }
70+ };
71+ }
72+})();
M internal/adapters/web/view.go +12 -0
@@ -69,6 +69,8 @@ type Layout struct {
6969 Passkeys bool
7070 // MR loads the script of the merge request page (mr.js).
7171 MR bool
72+ // IssueForm loads the script of the public issue form (issue-form.js).
73+ IssueForm bool
7274 }
7375
7476 // Link is a plain label + href pair.
@@ -135,6 +137,16 @@ type SortLink struct {
135137 type HomePage struct {
136138 Sorts []SortLink
137139 Rows []HomeRow
140+ // Inbox is the button to the owner's inbox; nil unless the owner is signed
141+ // in.
142+ Inbox *InboxButton
143+}
144+
145+// InboxButton is the link to the inbox with the number of open items; Count is
146+// negative when it could not be read.
147+type InboxButton struct {
148+ Href string
149+ Count int
138150 }
139151
140152 // Fact is one key/value row of the facts block.
@@ -69,6 +69,8 @@ type Layout struct {
69 Passkeys bool69 Passkeys bool
70 // MR loads the script of the merge request page (mr.js).70 // MR loads the script of the merge request page (mr.js).
71 MR bool71 MR bool
72+ // IssueForm loads the script of the public issue form (issue-form.js).
73+ IssueForm bool
72 }74 }
73 75
74 // Link is a plain label + href pair.76 // Link is a plain label + href pair.
@@ -135,6 +137,16 @@ type SortLink struct {
135 type HomePage struct {137 type HomePage struct {
136 Sorts []SortLink138 Sorts []SortLink
137 Rows []HomeRow139 Rows []HomeRow
140+ // Inbox is the button to the owner's inbox; nil unless the owner is signed
141+ // in.
142+ Inbox *InboxButton
143+}
144+
145+// InboxButton is the link to the inbox with the number of open items; Count is
146+// negative when it could not be read.
147+type InboxButton struct {
148+ Href string
149+ Count int
138 }150 }
139 151
140 // Fact is one key/value row of the facts block.152 // Fact is one key/value row of the facts block.
M internal/adapters/web/web.go +46 -1
@@ -54,6 +54,28 @@ type Config struct {
5454 // there are no such pages, nor section for them. Leave it unset rather
5555 // than assigning a typed nil pointer.
5656 MergeRequests ports.MergeRequestService
57+ // Issues serves the issue pages (ADR 0031). Without it there are no such
58+ // pages, nor tab for them. Leave it unset rather than assigning a typed
59+ // nil pointer.
60+ Issues ports.IssueService
61+ // IssueNumbers tells whether a number names an issue or a merge request,
62+ // so that the page of the wrong kind redirects to the right one. Without
63+ // it there is no such redirect. Leave it unset rather than assigning a
64+ // typed nil pointer.
65+ IssueNumbers ports.IssueNumberResolver
66+ // IssueForm serves the public issue form of repositories whose issues
67+ // mode is public (GET and POST /<repo>/issues/new for a visitor). Without
68+ // it there is no such form. Leave it unset rather than assigning a typed
69+ // nil pointer.
70+ IssueForm ports.IssueSubmissionService
71+ // Moderation serves the Approve, Reject and Reject-and-ban buttons of the
72+ // pending issues in the inbox. It needs Inbox. Without it there are no
73+ // such routes. Leave it unset rather than assigning a typed nil pointer.
74+ Moderation ports.IssueModerationService
75+ // Inbox serves the owner's inbox page (/-/inbox) and the button that
76+ // leads to it. Without it there is neither. Leave it unset rather than
77+ // assigning a typed nil pointer.
78+ Inbox ports.InboxService
5779 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
5880 // commits and merge requests. Without it there are no such pages. Leave it
5981 // unset rather than assigning a typed nil pointer.
@@ -166,6 +188,14 @@ func (s *Server) routes() *http.ServeMux {
166188 if s.passkeysOn() {
167189 s.passkeyRoutes(mux)
168190 }
191+ if s.cfg.Inbox != nil {
192+ mux.HandleFunc("GET "+inboxPath, s.page(s.inbox))
193+ if s.cfg.Moderation != nil {
194+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/approve", s.mrAction(s.pendingApprove))
195+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/reject", s.mrAction(s.pendingReject(false)))
196+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/ban", s.mrAction(s.pendingReject(true)))
197+ }
198+ }
169199 mux.HandleFunc("GET /{$}", s.page(s.home))
170200 mux.HandleFunc("GET /{repo}", s.page(s.summary))
171201 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -182,7 +212,7 @@ func (s *Server) routes() *http.ServeMux {
182212 if s.cfg.MergeRequests != nil {
183213 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
184214 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
185- mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))
215+ mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.pageOrRedirect(s.mrShow, s.mrToIssue))
186216 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
187217 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))
188218 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
@@ -197,6 +227,21 @@ func (s *Server) routes() *http.ServeMux {
197227 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
198228 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
199229 }
230+ if s.cfg.Issues != nil {
231+ mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))
232+ mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))
233+ mux.HandleFunc("GET /{repo}/issues/new", s.issueNewRoute)
234+ mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))
235+ // The writes: POST only, behind the gate (ServeHTTP).
236+ mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))
237+ mux.HandleFunc("POST /{repo}/issues/{n}/comments", s.issueAction(s.issueComment))
238+ mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))
239+ mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))
240+ }
241+ if s.cfg.IssueForm != nil {
242+ mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
243+ mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
244+ }
200245 if s.cfg.Runs != nil {
201246 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
202247 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
@@ -54,6 +54,28 @@ type Config struct {
54 // there are no such pages, nor section for them. Leave it unset rather54 // there are no such pages, nor section for them. Leave it unset rather
55 // than assigning a typed nil pointer.55 // than assigning a typed nil pointer.
56 MergeRequests ports.MergeRequestService56 MergeRequests ports.MergeRequestService
57+ // Issues serves the issue pages (ADR 0031). Without it there are no such
58+ // pages, nor tab for them. Leave it unset rather than assigning a typed
59+ // nil pointer.
60+ Issues ports.IssueService
61+ // IssueNumbers tells whether a number names an issue or a merge request,
62+ // so that the page of the wrong kind redirects to the right one. Without
63+ // it there is no such redirect. Leave it unset rather than assigning a
64+ // typed nil pointer.
65+ IssueNumbers ports.IssueNumberResolver
66+ // IssueForm serves the public issue form of repositories whose issues
67+ // mode is public (GET and POST /<repo>/issues/new for a visitor). Without
68+ // it there is no such form. Leave it unset rather than assigning a typed
69+ // nil pointer.
70+ IssueForm ports.IssueSubmissionService
71+ // Moderation serves the Approve, Reject and Reject-and-ban buttons of the
72+ // pending issues in the inbox. It needs Inbox. Without it there are no
73+ // such routes. Leave it unset rather than assigning a typed nil pointer.
74+ Moderation ports.IssueModerationService
75+ // Inbox serves the owner's inbox page (/-/inbox) and the button that
76+ // leads to it. Without it there is neither. Leave it unset rather than
77+ // assigning a typed nil pointer.
78+ Inbox ports.InboxService
57 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to79 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
58 // commits and merge requests. Without it there are no such pages. Leave it80 // commits and merge requests. Without it there are no such pages. Leave it
59 // unset rather than assigning a typed nil pointer.81 // unset rather than assigning a typed nil pointer.
@@ -166,6 +188,14 @@ func (s *Server) routes() *http.ServeMux {
166 if s.passkeysOn() {188 if s.passkeysOn() {
167 s.passkeyRoutes(mux)189 s.passkeyRoutes(mux)
168 }190 }
191+ if s.cfg.Inbox != nil {
192+ mux.HandleFunc("GET "+inboxPath, s.page(s.inbox))
193+ if s.cfg.Moderation != nil {
194+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/approve", s.mrAction(s.pendingApprove))
195+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/reject", s.mrAction(s.pendingReject(false)))
196+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/ban", s.mrAction(s.pendingReject(true)))
197+ }
198+ }
169 mux.HandleFunc("GET /{$}", s.page(s.home))199 mux.HandleFunc("GET /{$}", s.page(s.home))
170 mux.HandleFunc("GET /{repo}", s.page(s.summary))200 mux.HandleFunc("GET /{repo}", s.page(s.summary))
171 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))201 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -182,7 +212,7 @@ func (s *Server) routes() *http.ServeMux {
182 if s.cfg.MergeRequests != nil {212 if s.cfg.MergeRequests != nil {
183 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))213 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
184 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))214 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
185- mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))215+ mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.pageOrRedirect(s.mrShow, s.mrToIssue))
186 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))216 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
187 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))217 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))
188 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))218 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
@@ -197,6 +227,21 @@ func (s *Server) routes() *http.ServeMux {
197 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))227 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
198 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))228 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
199 }229 }
230+ if s.cfg.Issues != nil {
231+ mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))
232+ mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))
233+ mux.HandleFunc("GET /{repo}/issues/new", s.issueNewRoute)
234+ mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))
235+ // The writes: POST only, behind the gate (ServeHTTP).
236+ mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))
237+ mux.HandleFunc("POST /{repo}/issues/{n}/comments", s.issueAction(s.issueComment))
238+ mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))
239+ mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))
240+ }
241+ if s.cfg.IssueForm != nil {
242+ mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
243+ mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
244+ }
200 if s.cfg.Runs != nil {245 if s.cfg.Runs != nil {
201 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))246 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
202 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))247 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
M internal/app/api_helpers_test.go +2 -0
M internal/app/api_test.go +1 -1
M internal/app/cli_e2e_test.go +2 -2
M internal/app/cli_fake_test.go +27 -0
A internal/app/cli_issue.go +455 -0
A internal/app/cli_issue_e2e_test.go +154 -0
A internal/app/cli_issue_test.go +264 -0
M internal/app/cli_repo.go +61 -6
M internal/app/cli_repo_test.go +6 -5
M internal/app/cli_token.go +22 -0
M internal/app/cli_token_test.go +2 -3
M internal/app/cli_version.go +1 -0
M internal/app/cli_version_test.go +5 -5
M internal/app/cli_watch_test.go +1 -1
M internal/app/forge.go +71 -22
A internal/app/issue_api_test.go +245 -0
A internal/app/issue_moderation_api_test.go +139 -0
M internal/app/root.go +1 -1
M internal/app/testdata/web/blob-binary.html +3 -1
M internal/app/testdata/web/blob-code.html +3 -1
M internal/app/testdata/web/blob-empty.html +3 -1
M internal/app/testdata/web/blob-image.html +3 -1
M internal/app/testdata/web/blob-markdown.html +3 -1
M internal/app/testdata/web/blob-svg.html +3 -1
M internal/app/testdata/web/blob-text.html +3 -1
M internal/app/testdata/web/blob-too-large.html +3 -1
M internal/app/testdata/web/blob-trojan.html +3 -1
M internal/app/testdata/web/commit-big.html +3 -1
M internal/app/testdata/web/commit-binary.html +3 -1
M internal/app/testdata/web/commit-merge.html +3 -1
M internal/app/testdata/web/commit-modify-split.html +3 -1
M internal/app/testdata/web/commit-modify.html +3 -1
M internal/app/testdata/web/commit-rename.html +3 -1
M internal/app/testdata/web/commit-root.html +3 -1
M internal/app/testdata/web/commit-trojan.html +3 -1
M internal/app/testdata/web/commits-page2.html +3 -1
M internal/app/testdata/web/commits-path.html +3 -1
M internal/app/testdata/web/commits-root.html +4 -2
M internal/app/testdata/web/commits-small.html +4 -2
M internal/app/testdata/web/empty.html +3 -1
M internal/app/testdata/web/home.html +4 -2
M internal/app/testdata/web/not-found-page.html +3 -1
M internal/app/testdata/web/not-found.html +3 -1
M internal/app/testdata/web/refs.html +4 -2
M internal/app/testdata/web/summary.html +4 -2
M internal/app/testdata/web/tree-nested.html +3 -1
M internal/app/testdata/web/tree-root.html +3 -1
M internal/app/testdata/web/tree-slash-ref.html +3 -1
M internal/app/testdata/web/tree-subdir.html +3 -1
M internal/app/web.go +5 -0
A internal/app/web_inbox_test.go +152 -0
A internal/app/web_issue_form_test.go +231 -0
A internal/app/web_issue_test.go +139 -0
M internal/clientlimit/export_test.go +7 -0
A internal/clientlimit/submissions.go +178 -0
A internal/clientlimit/submissions_test.go +148 -0
M internal/domain/event.go +72 -0
A internal/domain/issue.go +106 -0
A internal/domain/issue_test.go +138 -0
M internal/domain/mergerequest.go +3 -1
A internal/domain/pending_issue.go +122 -0
A internal/domain/pending_issue_test.go +90 -0
A internal/domain/submission.go +42 -0
M internal/domain/text.go +102 -0
M internal/domain/token.go +5 -1
M internal/domain/token_test.go +6 -1
M internal/ports/dto.go +69 -0
A internal/ports/mocks/InboxService.go +121 -0
A internal/ports/mocks/IssueBanStore.go +183 -0
A internal/ports/mocks/IssueKeyStore.go +109 -0
A internal/ports/mocks/IssueModerationService.go +280 -0
A internal/ports/mocks/IssueNumberResolver.go +126 -0
A internal/ports/mocks/IssueService.go +523 -0
A internal/ports/mocks/IssueStore.go +515 -0
A internal/ports/mocks/IssueSubmissionService.go +190 -0
M internal/ports/mocks/MergeRequestStore.go +62 -0
A internal/ports/mocks/PendingIssueStore.go +452 -0
A internal/ports/mocks/SubmissionLimiter.go +188 -0
M internal/ports/ports.go +81 -0
M internal/ports/services.go +94 -0
M internal/usecase/events.go +97 -3
A internal/usecase/events_owner_test.go +200 -0
M internal/usecase/events_test.go +43 -0
M internal/usecase/export_test.go +34 -0
A internal/usecase/inbox.go +164 -0
A internal/usecase/inbox_test.go +110 -0
A internal/usecase/issue_keys.go +62 -0
A internal/usecase/issue_moderation.go +119 -0
A internal/usecase/issue_moderation_test.go +251 -0
A internal/usecase/issue_pow_vector_test.go +39 -0
A internal/usecase/issue_submissions.go +383 -0
A internal/usecase/issue_submissions_test.go +670 -0
A internal/usecase/issues.go +291 -0
A internal/usecase/issues_integration_test.go +87 -0
A internal/usecase/issues_test.go +276 -0
M internal/usecase/mergerequests_integration_test.go +1 -1
M internal/usecase/repos_write.go +13 -3
M internal/usecase/repos_write_test.go +42 -0
A proto/ermite/storage/issue.proto +71 -0
M proto/ermite/storage/merge_request.proto +10 -0
M skills/ermite/CLI.md +15 -0

Comments0 open / 0

no comments yet.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close