forked from
tangled.org/core
Monorepo for Tangled
1package labels
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "log/slog"
9 "net/http"
10 "time"
11
12 "tangled.org/core/api/tangled"
13 "tangled.org/core/appview/db"
14 "tangled.org/core/appview/knotacl"
15 "tangled.org/core/appview/middleware"
16 "tangled.org/core/appview/models"
17 "tangled.org/core/appview/notify"
18 "tangled.org/core/appview/oauth"
19 "tangled.org/core/appview/pages"
20 "tangled.org/core/orm"
21 "tangled.org/core/tid"
22
23 comatproto "github.com/bluesky-social/indigo/api/atproto"
24 "github.com/bluesky-social/indigo/atproto/atclient"
25 "github.com/bluesky-social/indigo/atproto/identity"
26 "github.com/bluesky-social/indigo/atproto/syntax"
27 lexutil "github.com/bluesky-social/indigo/lex/util"
28 "github.com/go-chi/chi/v5"
29)
30
31type Labels struct {
32 oauth *oauth.OAuth
33 pages *pages.Pages
34 db *db.DB
35 dir identity.Directory
36 logger *slog.Logger
37 acl *knotacl.Service
38 notifier notify.Notifier
39}
40
41func New(
42 oauth *oauth.OAuth,
43 pages *pages.Pages,
44 db *db.DB,
45 dir identity.Directory,
46 acl *knotacl.Service,
47 notifier notify.Notifier,
48 logger *slog.Logger,
49) *Labels {
50 return &Labels{
51 oauth: oauth,
52 pages: pages,
53 db: db,
54 dir: dir,
55 logger: logger,
56 acl: acl,
57 notifier: notifier,
58 }
59}
60
61func (l *Labels) Router() http.Handler {
62 r := chi.NewRouter()
63
64 r.Use(middleware.AuthMiddleware(l.oauth))
65 r.Put("/perform", l.PerformLabelOp)
66
67 return r
68}
69
70// this is a tricky handler implementation:
71// - the user selects the new state of all the labels in the label panel and hits save
72// - this handler should calculate the diff in order to create the labelop record
73// - we need the diff in order to maintain a "history" of operations performed by users
74func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) {
75 user := l.oauth.GetMultiAccountUser(r)
76
77 noticeId := "add-label-error"
78
79 fail := func(msg string, err error) {
80 l.logger.Error("failed to add label", "err", err)
81 l.pages.Notice(w, noticeId, msg)
82 }
83
84 if err := r.ParseForm(); err != nil {
85 fail("Invalid form.", err)
86 return
87 }
88
89 did := user.Did
90 rkey := tid.TID()
91 performedAt := time.Now()
92 repoAt := r.Form.Get("repo")
93 subjectUri := r.Form.Get("subject")
94
95 repo, err := db.GetRepo(l.db, orm.FilterEq("at_uri", repoAt))
96 if err != nil {
97 fail("Failed to get repository.", err)
98 return
99 }
100
101 // find all the labels that this repo subscribes to
102 repoLabels, err := db.GetRepoLabels(l.db, orm.FilterEq("repo_did", repo.RepoDid))
103 if err != nil {
104 fail("Failed to get labels for this repository.", err)
105 return
106 }
107
108 var labelAts []string
109 for _, rl := range repoLabels {
110 labelAts = append(labelAts, rl.LabelAt.String())
111 }
112
113 actx, err := db.NewLabelApplicationCtx(l.db, orm.FilterIn("at_uri", labelAts))
114 if err != nil {
115 fail("Invalid form data.", err)
116 return
117 }
118
119 // calculate the start state by applying already known labels
120 existingOps, err := db.GetLabelOps(l.db, orm.FilterEq("subject", subjectUri))
121 if err != nil {
122 fail("Invalid form data.", err)
123 return
124 }
125
126 labelState := models.NewLabelState()
127 actx.ApplyLabelOps(labelState, existingOps)
128
129 var labelOps []models.LabelOp
130
131 // first delete all existing state
132 for key, vals := range labelState.Inner() {
133 for val := range vals {
134 labelOps = append(labelOps, models.LabelOp{
135 Did: did,
136 Rkey: rkey,
137 Subject: syntax.ATURI(subjectUri),
138 Operation: models.LabelOperationDel,
139 OperandKey: key,
140 OperandValue: val,
141 PerformedAt: performedAt,
142 })
143 }
144 }
145
146 // add all the new state the user specified
147 for key, vals := range r.Form {
148 if _, ok := actx.Defs[key]; !ok {
149 continue
150 }
151
152 for _, val := range vals {
153 labelOps = append(labelOps, models.LabelOp{
154 Did: did,
155 Rkey: rkey,
156 Subject: syntax.ATURI(subjectUri),
157 Operation: models.LabelOperationAdd,
158 OperandKey: key,
159 OperandValue: val,
160 PerformedAt: performedAt,
161 })
162 }
163 }
164
165 for i := range labelOps {
166 def := actx.Defs[labelOps[i].OperandKey]
167 op := labelOps[i]
168
169 // validate permissions: only collaborators can apply labels currently
170 //
171 // TODO: introduce a repo:triage permission
172 ok, err := l.acl.HasRepoPermissionErr(r.Context(), repo, op.Did, "repo:push")
173 if err != nil {
174 fail("Failed to enforce permissions. Please try again later", fmt.Errorf("enforcing permission: %w", err))
175 return
176 }
177 if !ok {
178 fail("Unauthorized label operation", fmt.Errorf("unauthorized label operation"))
179 return
180 }
181
182 // resolve Handle to DID
183 if def.ValueType.IsString() && def.ValueType.IsDidFormat() {
184 val := syntax.AtIdentifier(op.OperandValue)
185 if val.IsHandle() {
186 ident, err := l.dir.Lookup(r.Context(), val)
187 if err != nil {
188 fail(fmt.Sprintf("Failed to resolve handle %q: %s", val, err), err)
189 }
190 op.OperandValue = ident.DID.String()
191 }
192 }
193
194 if err := def.ValidateOperandValue(&op); err != nil {
195 fail(fmt.Sprintf("Invalid form data: %s", err), err)
196 return
197 }
198 labelOps[i] = op
199 }
200
201 // reduce the opset
202 labelOps = models.ReduceLabelOps(labelOps)
203
204 // next, apply all ops introduced in this request and filter out ones that are no-ops
205 validLabelOps := labelOps[:0]
206 for _, op := range labelOps {
207 if err = actx.ApplyLabelOp(labelState, op); err != models.LabelNoOpError {
208 validLabelOps = append(validLabelOps, op)
209 }
210 }
211
212 // nothing to do
213 if len(validLabelOps) == 0 {
214 l.pages.HxRefresh(w)
215 return
216 }
217
218 // create an atproto record of valid ops
219 record := models.LabelOpsAsRecord(validLabelOps)
220
221 client, err := l.oauth.AuthorizedClient(r)
222 if err != nil {
223 fail("Failed to authorize user.", err)
224 return
225 }
226
227 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
228 Collection: tangled.LabelOpNSID,
229 Repo: did,
230 Rkey: rkey,
231 Record: &lexutil.LexiconTypeDecoder{
232 Val: &record,
233 },
234 })
235 if err != nil {
236 fail("Failed to create record on PDS for user.", err)
237 return
238 }
239 atUri := resp.Uri
240
241 tx, err := l.db.BeginTx(r.Context(), nil)
242 if err != nil {
243 fail("Failed to update labels. Try again later.", err)
244 return
245 }
246
247 rollback := func() {
248 err1 := tx.Rollback()
249 err2 := rollbackRecord(context.Background(), atUri, client)
250
251 // ignore txn complete errors, this is okay
252 if errors.Is(err1, sql.ErrTxDone) {
253 err1 = nil
254 }
255
256 if errs := errors.Join(err1, err2); errs != nil {
257 return
258 }
259 }
260 defer rollback()
261
262 for _, o := range validLabelOps {
263 if _, err := db.AddLabelOp(tx, &o); err != nil {
264 fail("Failed to update labels. Try again later.", err)
265 return
266 }
267 }
268
269 err = tx.Commit()
270 if err != nil {
271 return
272 }
273
274 // clear aturi when everything is successful
275 atUri = ""
276
277 subject := syntax.ATURI(subjectUri)
278 if subject.Collection() == tangled.RepoIssueNSID {
279 issues, err := db.GetIssues(l.db, orm.FilterEq("at_uri", subjectUri))
280 if err == nil && len(issues) == 1 {
281 l.notifier.NewIssueLabelOp(r.Context(), syntax.DID(did), &issues[0], validLabelOps)
282 }
283 }
284 if subject.Collection() == tangled.RepoPullNSID {
285 pulls, err := db.GetPulls(l.db, orm.FilterEq("at_uri", subjectUri))
286 if err == nil && len(pulls) == 1 {
287 l.notifier.NewPullLabelOp(r.Context(), syntax.DID(did), pulls[0], validLabelOps)
288 }
289 }
290
291 l.pages.HxRefresh(w)
292}
293
294// this is used to rollback changes made to the PDS
295//
296// it is a no-op if the provided ATURI is empty
297func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error {
298 if aturi == "" {
299 return nil
300 }
301
302 parsed := syntax.ATURI(aturi)
303
304 collection := parsed.Collection().String()
305 repo := parsed.Authority().String()
306 rkey := parsed.RecordKey().String()
307
308 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
309 Collection: collection,
310 Repo: repo,
311 Rkey: rkey,
312 })
313 return err
314}