Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / labels / labels.go
7.8 kB 314 lines
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}