~bigbes/sr-ht-spec

ref: e849de2a744d6ec98154c2823e7c4e8c2ffb3116 sr-ht-spec/web/inbox.go -rw-r--r-- 4.8 KiB
e849de2a — Eugene Blikh chore(beads): close spec-ejq.2, CI publish is green on build #251 13 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
package web

import (
	"net/http"
	"strconv"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// inboxData is the review-queue page: the open proposals waiting on the owner,
// and the digest of what auto-merged without stopping for review.
//
// NewCount is how many leading digest rows auto-merged since the owner last
// marked it seen. The digest is newest-first and "new" means merged after the
// mark, so the new rows are exactly the first NewCount — the template draws the
// "since you last looked" divider after them and shows the mark-as-seen action
// only when there is something new to clear.
type inboxData struct {
	Open     []proposalRow
	Digest   []proposalRow
	NewCount int
}

// proposalRow is one proposal as a listing line: enough to decide whether to
// open it, and the link that does. New marks a digest row that auto-merged
// since the owner last looked; it is always false for the open queue.
type proposalRow struct {
	Href     string
	ID       int
	Space    string
	Title    string
	Agent    string
	Approval string
	New      bool
}

// handleInbox renders the review queue: every open proposal on the instance,
// plus the digest of recently policy-merged content. It is the backstop the
// design describes — the link an agent hands you is the normal way in, and this
// catches the work no link reached.
func (s *Server) handleInbox(w http.ResponseWriter, r *http.Request) {
	if !mayRead(r) {
		s.loginRedirect(w, r)
		return
	}
	open, err := s.reader.Inbox(r.Context())
	if err != nil {
		s.fail(w, r, err)
		return
	}
	digest, err := s.reader.Digest(r.Context())
	if err != nil {
		s.fail(w, r, err)
		return
	}
	// The GET stays pure: it reads the mark to draw the divider but never moves
	// it. Advancing is handleInboxSeen's job, behind a POST.
	mark, marked, err := s.reader.DigestMark(r.Context())
	if err != nil {
		s.fail(w, r, err)
		return
	}

	digestRows, newCount := digestRows(digest, mark, marked)

	vd := s.chrome(r)
	vd.Title = "Review queue"
	vd.Data = inboxData{
		Open:     proposalRows(open),
		Digest:   digestRows,
		NewCount: newCount,
	}
	s.render(w, http.StatusOK, "inbox", vd)
}

// handleInboxSeen advances the owner's digest mark to now, then redirects back
// to the queue so a reload does not re-submit. It is the one write the review
// queue makes; keeping it a POST is what lets handleInbox stay a pure read.
//
// Only the owner may move their own mark, and the cross-site guard is the same
// one approve/reject use — the CSRF defense a form post needs when the session
// cookie is meta's and this service cannot set its SameSite.
func (s *Server) handleInboxSeen(w http.ResponseWriter, r *http.Request) {
	if !authn.PrincipalFromContext(r.Context()).IsOwner() {
		s.renderError(w, r, http.StatusForbidden, "only the instance owner may mark the digest seen")
		return
	}
	if !s.sameOrigin(r) {
		s.renderError(w, r, http.StatusForbidden, "this request did not originate from this site")
		return
	}
	if err := s.reader.MarkDigestSeen(r.Context(), time.Now()); err != nil {
		s.fail(w, r, err)
		return
	}
	http.Redirect(w, r, "/inbox", http.StatusSeeOther)
}

// proposalRows turns service proposals into listing rows for the open queue,
// where nothing is ever "new".
func proposalRows(ps []service.Proposal) []proposalRow {
	rows := make([]proposalRow, 0, len(ps))
	for _, p := range ps {
		rows = append(rows, proposalRowOf(p))
	}
	return rows
}

// digestRows turns the digest proposals into rows, flagging each that
// auto-merged after the mark as new and counting them. With no mark yet
// (marked false) the whole digest is new — the owner has never cleared it. The
// digest arrives newest-first and a row is new iff its merge time is after the
// mark, so the new rows are the leading run and newCount is their length.
func digestRows(ps []service.Proposal, mark time.Time, marked bool) ([]proposalRow, int) {
	rows := make([]proposalRow, 0, len(ps))
	newCount := 0
	for _, p := range ps {
		row := proposalRowOf(p)
		row.New = !marked || (p.Resolved != nil && p.Resolved.After(mark))
		if row.New {
			newCount++
		}
		rows = append(rows, row)
	}
	return rows, newCount
}

// proposalRowOf builds one listing row, deriving its link from the space and id
// — the same stable /~owner/space/p/<id> shape the write plane hands back.
func proposalRowOf(p service.Proposal) proposalRow {
	return proposalRow{
		Href:     proposalHref(p),
		ID:       p.ID,
		Space:    p.Space.String(),
		Title:    p.Title,
		Agent:    p.Agent,
		Approval: string(p.Approval),
	}
}

// proposalHref is the review-page link for a proposal: the same path the
// proposal URL uses, minus the origin, so it works as a relative link in the UI.
func proposalHref(p service.Proposal) string {
	return "/" + p.Space.String() + "/p/" + strconv.Itoa(p.ID)
}