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