This repository has no description
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 indexedAt := time.Now()
93 repoAt := r.Form.Get("repo")
94 subjectUri := r.Form.Get("subject")
95
96 repo, err := db.GetRepo(l.db, orm.FilterEq("at_uri", repoAt))
97 if err != nil {
98 fail("Failed to get repository.", err)
99 return
100 }
101
102 // find all the labels that this repo subscribes to
103 repoLabels, err := db.GetRepoLabels(l.db, orm.FilterEq("repo_did", repo.RepoDid))
104 if err != nil {
105 fail("Failed to get labels for this repository.", err)
106 return
107 }
108
109 var labelAts []string
110 for _, rl := range repoLabels {
111 labelAts = append(labelAts, rl.LabelAt.String())
112 }
113
114 actx, err := db.NewLabelApplicationCtx(l.db, orm.FilterIn("at_uri", labelAts))
115 if err != nil {
116 fail("Invalid form data.", err)
117 return
118 }
119
120 // calculate the start state by applying already known labels
121 existingOps, err := db.GetLabelOps(l.db, orm.FilterEq("subject", subjectUri))
122 if err != nil {
123 fail("Invalid form data.", err)
124 return
125 }
126
127 labelState := models.NewLabelState()
128 actx.ApplyLabelOps(labelState, existingOps)
129
130 var labelOps []models.LabelOp
131
132 // first delete all existing state
133 for key, vals := range labelState.Inner() {
134 for val := range vals {
135 labelOps = append(labelOps, models.LabelOp{
136 Did: did,
137 Rkey: rkey,
138 Subject: syntax.ATURI(subjectUri),
139 Operation: models.LabelOperationDel,
140 OperandKey: key,
141 OperandValue: val,
142 PerformedAt: performedAt,
143 IndexedAt: indexedAt,
144 })
145 }
146 }
147
148 // add all the new state the user specified
149 for key, vals := range r.Form {
150 if _, ok := actx.Defs[key]; !ok {
151 continue
152 }
153
154 for _, val := range vals {
155 labelOps = append(labelOps, models.LabelOp{
156 Did: did,
157 Rkey: rkey,
158 Subject: syntax.ATURI(subjectUri),
159 Operation: models.LabelOperationAdd,
160 OperandKey: key,
161 OperandValue: val,
162 PerformedAt: performedAt,
163 IndexedAt: indexedAt,
164 })
165 }
166 }
167
168 for i := range labelOps {
169 def := actx.Defs[labelOps[i].OperandKey]
170 op := labelOps[i]
171
172 // validate permissions: only collaborators can apply labels currently
173 //
174 // TODO: introduce a repo:triage permission
175 ok, err := l.acl.HasRepoPermissionErr(r.Context(), repo, op.Did, "repo:push")
176 if err != nil {
177 fail("Failed to enforce permissions. Please try again later", fmt.Errorf("enforcing permission: %w", err))
178 return
179 }
180 if !ok {
181 fail("Unauthorized label operation", fmt.Errorf("unauthorized label operation"))
182 return
183 }
184
185 // resolve Handle to DID
186 if def.ValueType.IsString() && def.ValueType.IsDidFormat() {
187 val := syntax.AtIdentifier(op.OperandValue)
188 if val.IsHandle() {
189 ident, err := l.dir.Lookup(r.Context(), val)
190 if err != nil {
191 fail(fmt.Sprintf("Failed to resolve handle %q: %s", val, err), err)
192 }
193 op.OperandValue = ident.DID.String()
194 }
195 }
196
197 if err := def.ValidateOperandValue(&op); err != nil {
198 fail(fmt.Sprintf("Invalid form data: %s", err), err)
199 return
200 }
201 labelOps[i] = op
202 }
203
204 // reduce the opset
205 labelOps = models.ReduceLabelOps(labelOps)
206
207 // next, apply all ops introduced in this request and filter out ones that are no-ops
208 validLabelOps := labelOps[:0]
209 for _, op := range labelOps {
210 if err = actx.ApplyLabelOp(labelState, op); err != models.LabelNoOpError {
211 validLabelOps = append(validLabelOps, op)
212 }
213 }
214
215 // nothing to do
216 if len(validLabelOps) == 0 {
217 l.pages.HxRefresh(w)
218 return
219 }
220
221 // create an atproto record of valid ops
222 record := models.LabelOpsAsRecord(validLabelOps)
223
224 client, err := l.oauth.AuthorizedClient(r)
225 if err != nil {
226 fail("Failed to authorize user.", err)
227 return
228 }
229
230 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
231 Collection: tangled.LabelOpNSID,
232 Repo: did,
233 Rkey: rkey,
234 Record: &lexutil.LexiconTypeDecoder{
235 Val: &record,
236 },
237 })
238 if err != nil {
239 fail("Failed to create record on PDS for user.", err)
240 return
241 }
242 atUri := resp.Uri
243
244 tx, err := l.db.BeginTx(r.Context(), nil)
245 if err != nil {
246 fail("Failed to update labels. Try again later.", err)
247 return
248 }
249
250 rollback := func() {
251 err1 := tx.Rollback()
252 err2 := rollbackRecord(context.Background(), atUri, client)
253
254 // ignore txn complete errors, this is okay
255 if errors.Is(err1, sql.ErrTxDone) {
256 err1 = nil
257 }
258
259 if errs := errors.Join(err1, err2); errs != nil {
260 return
261 }
262 }
263 defer rollback()
264
265 for _, o := range validLabelOps {
266 if _, err := db.AddLabelOp(l.db, &o); err != nil {
267 fail("Failed to update labels. Try again later.", err)
268 return
269 }
270 }
271
272 err = tx.Commit()
273 if err != nil {
274 return
275 }
276
277 // clear aturi when everything is successful
278 atUri = ""
279
280 subject := syntax.ATURI(subjectUri)
281 if subject.Collection() == tangled.RepoIssueNSID {
282 issues, err := db.GetIssues(l.db, orm.FilterEq("at_uri", subjectUri))
283 if err == nil && len(issues) == 1 {
284 l.notifier.NewIssueLabelOp(r.Context(), syntax.DID(did), &issues[0], validLabelOps)
285 }
286 }
287 if subject.Collection() == tangled.RepoPullNSID {
288 pulls, err := db.GetPulls(l.db, orm.FilterEq("at_uri", subjectUri))
289 if err == nil && len(pulls) == 1 {
290 l.notifier.NewPullLabelOp(r.Context(), syntax.DID(did), pulls[0], validLabelOps)
291 }
292 }
293
294 l.pages.HxRefresh(w)
295}
296
297// this is used to rollback changes made to the PDS
298//
299// it is a no-op if the provided ATURI is empty
300func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error {
301 if aturi == "" {
302 return nil
303 }
304
305 parsed := syntax.ATURI(aturi)
306
307 collection := parsed.Collection().String()
308 repo := parsed.Authority().String()
309 rkey := parsed.RecordKey().String()
310
311 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
312 Collection: collection,
313 Repo: repo,
314 Rkey: rkey,
315 })
316 return err
317}