web: mark-read dispatches notifications read; #261 rows in step !522

merged merged by cmc on 2026-09-29 04:46 UTC · krz/gitbay:dispatch-markread-261 into main

6 files changed, +55 −13

Layout: unified · split

.gitbay/wiki/Architecture/04-Trust-Boundaries.org +2 −2
@@ -93,8 +93,8 @@ no HTTP write path (=internal/httpd/smart.go=,
9393 decode the JSON result into the template (=internal/httpd/control.go=).
94943. Form posts pass =checkOrigin= (=accounts.go=), dispatch the
9595 matching command, and map the exit code to a redirect or an error on
96 the page. Three toggles (pin, watch, mark read) write the store
97 directly instead (#261).
96 the page. The pin, watch and mark-read toggles dispatch =repo pin=,
97 =repo watch=/=mute=/=unwatch= and =notifications read= the same way.
9898
9999** E. JSON API
100100
.gitbay/wiki/Architecture/09-Controls.org +2 −2
@@ -9,7 +9,7 @@ chapter names of OWASP ASVS 4.0 where one fits.
99
1010| Control | Status | Evidence |
1111|---------------------------------------------+----------+------------------------------------------------------------------|
12| One authorization path for every surface | partial | all surfaces call =control.Dispatch= (=internal/control/control.go=); three web toggles write the store directly (#261) |
12| One authorization path for every surface | in place | all surfaces call =control.Dispatch= (=internal/control/control.go=); web form handlers, including the pin, watch and mark-read toggles, dispatch commands; login and session bookkeeping are not commands |
1313| No server-side signing key | in place | =internal/sig= verifies only |
1414| Least functionality by default | in place | API, web accounts, git://, push and registration default off (=internal/config/config.go=) |
1515| No git library; git runs as a subprocess with built argv | in place | =internal/gitutil= |
@@ -100,5 +100,5 @@ chapter names of OWASP ASVS 4.0 where one fits.
100100| Service hardening | in place | systemd sandboxing ([[file:03-Deployment.org][3]]) |
101101| Backups offsite and append-only | in place | restic with append-only credentials (documented) |
102102| Restore tested | gap | tooling in place (=admin restore-drill=, Admin wiki "Restore drill"); clean-host drill pending (#259) |
103| Migrations validated before commit | gap | foreign-key check runs after commit (#261) |
103| Migrations validated before commit | in place | =PRAGMA foreign_key_check= runs inside the migration transaction, before commit (=internal/store/store.go=) |
104104| Signed, reviewed changes to production | in place | signed commits, =require-mr=, ff-only merges, clean-tree deploys |
.gitbay/wiki/Architecture/10-Known-Gaps.org −1
@@ -11,7 +11,6 @@ what the 2026-09-27 review found; remove a row when its issue closes.
1111| Issue | Area | Gap | Severity |
1212|-------+------------------+-----------------------------------------------------------------------+----------|
1313| #259 | Recovery | No restore has been exercised; the procedure and tooling (=admin restore-drill=, =backup --verify=) are in place, the clean-host drill is pending | high |
14| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium |
1514
1615* Not filed
1716
CHANGELOG.org +2
@@ -6,6 +6,8 @@ anything beyond "replace the binary and restart" is needed.
66
77* Unreleased
88
9- Marking notifications read from the web dispatches =notifications
10 read=, so it counts against the write budget and is audited (#261).
911- =gitbayd admin backup --verify= also checks every release asset the
1012 database names against its recorded size and sha256, and every
1113 archived LFS object against its name (#259).
internal/httpd/account_test.go +43
@@ -498,3 +498,46 @@ func TestAccountTokenCreateAuditOmitsToken(t *testing.T) {
498498 t.Fatal("no cmd token create audit row")
499499 }
500500}
501
502// Marking notices read goes through notifications read, so it carries the
503// audit trail and write budget of the CLI command (#261).
504func TestNotificationsReadDispatches(t *testing.T) {
505 st, err := store.Open(":memory:")
506 if err != nil {
507 t.Fatal(err)
508 }
509 defer st.Close()
510 if err := st.MigrateUp(); err != nil {
511 t.Fatal(err)
512 }
513 uid, err := st.CreateUser("alice", false)
514 if err != nil {
515 t.Fatal(err)
516 }
517 u := store.User{ID: uid, Username: "alice"}
518 repoID, err := st.CreateRepo("user", uid, "app", "public")
519 if err != nil {
520 t.Fatal(err)
521 }
522 for i := 0; i < 2; i++ {
523 if err := st.AddNotice(uid, repoID, "issue", "bob", "s", "alice/app/issues/1"); err != nil {
524 t.Fatal(err)
525 }
526 }
527 s := New(config.Default(), st, nil)
528
529 notices, _ := st.Inbox(uid, true, 10, 0)
530 req := httptest.NewRequest("POST", "/notifications/read", strings.NewReader("id="+strconv.FormatInt(notices[0].ID, 10)))
531 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
532 s.notificationsRead(httptest.NewRecorder(), req, u)
533 if n := st.UnreadNotices(uid); n != 1 {
534 t.Fatalf("unread after one id = %d, want 1", n)
535 }
536 assertAudited(t, st, "cmd notifications read")
537
538 req = httptest.NewRequest("POST", "/notifications/read", nil)
539 s.notificationsRead(httptest.NewRecorder(), req, u)
540 if n := st.UnreadNotices(uid); n != 0 {
541 t.Fatalf("unread after all = %d, want 0", n)
542 }
543}
internal/httpd/notifyweb.go +6 −8
@@ -38,20 +38,18 @@ func (s *Server) notifications(w http.ResponseWriter, r *http.Request, u store.U
3838}
3939
4040// notificationsRead marks one notice read, or the whole inbox when no id
41// is given, then returns to the list.
41// is given, through notifications read, then returns to the list (#261).
4242func (s *Server) notificationsRead(w http.ResponseWriter, r *http.Request, u store.User) {
43 var ids []int64
43 argv := []string{"notifications", "read", "--all"}
4444 if v := r.FormValue("id"); v != "" {
45 n, err := strconv.ParseInt(v, 10, 64)
46 if err != nil {
45 if _, err := strconv.ParseInt(v, 10, 64); err != nil {
4746 http.Error(w, "bad id", http.StatusBadRequest)
4847 return
4948 }
50 ids = append(ids, n)
49 argv = []string{"notifications", "read", v}
5150 }
52 if _, err := s.st.MarkNoticesRead(u.ID, ids); err != nil {
53 http.Error(w, "internal error", http.StatusInternalServerError)
54 return
51 if _, msg, ok := s.runControl(u, argv); !ok {
52 s.setFlash(w, msg)
5553 }
5654 http.Redirect(w, r, "/notifications", http.StatusSeeOther)
5755}