This repository has no description
0

Configure Feed

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

core / appview / labels / labels.go
7.9 kB 317 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 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}