Monorepo for Tangled
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315package labels
import ( "context" "database/sql" "errors" "fmt" "log/slog" "net/http" "time"
"tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/knotacl" "tangled.org/core/appview/middleware" "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/orm" "tangled.org/core/tid"
comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/go-chi/chi/v5")
type Labels struct { oauth *oauth.OAuth pages *pages.Pages db *db.DB dir identity.Directory logger *slog.Logger acl *knotacl.Service notifier notify.Notifier}
func New( oauth *oauth.OAuth, pages *pages.Pages, db *db.DB, dir identity.Directory, acl *knotacl.Service, notifier notify.Notifier, logger *slog.Logger,) *Labels { return &Labels{ oauth: oauth, pages: pages, db: db, dir: dir, logger: logger, acl: acl, notifier: notifier, }}
func (l *Labels) Router() http.Handler { r := chi.NewRouter()
r.Use(middleware.AuthMiddleware(l.oauth)) r.Put("/perform", l.PerformLabelOp)
return r}
// this is a tricky handler implementation:// - the user selects the new state of all the labels in the label panel and hits save// - this handler should calculate the diff in order to create the labelop record// - we need the diff in order to maintain a "history" of operations performed by usersfunc (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { user := l.oauth.GetMultiAccountUser(r)
noticeId := "add-label-error"
fail := func(msg string, err error) { l.logger.Error("failed to add label", "err", err) l.pages.Notice(w, noticeId, msg) }
if err := r.ParseForm(); err != nil { fail("Invalid form.", err) return }
did := user.Did rkey := tid.TID() performedAt := time.Now() repoAt := r.Form.Get("repo") subjectUri := r.Form.Get("subject")
repo, err := db.GetRepo(l.db, orm.FilterEq("at_uri", repoAt)) if err != nil { fail("Failed to get repository.", err) return }
// find all the labels that this repo subscribes to repoLabels, err := db.GetRepoLabels(l.db, orm.FilterEq("repo_did", repo.RepoDid)) if err != nil { fail("Failed to get labels for this repository.", err) return }
var labelAts []string for _, rl := range repoLabels { labelAts = append(labelAts, rl.LabelAt.String()) }
actx, err := db.NewLabelApplicationCtx(l.db, orm.FilterIn("at_uri", labelAts)) if err != nil { fail("Invalid form data.", err) return }
// calculate the start state by applying already known labels existingOps, err := db.GetLabelOps(l.db, orm.FilterEq("subject", subjectUri)) if err != nil { fail("Invalid form data.", err) return }
labelState := models.NewLabelState() actx.ApplyLabelOps(labelState, existingOps)
var labelOps []models.LabelOp
// first delete all existing state for key, vals := range labelState.Inner() { for val := range vals { labelOps = append(labelOps, models.LabelOp{ Did: did, Rkey: rkey, Subject: syntax.ATURI(subjectUri), Operation: models.LabelOperationDel, OperandKey: key, OperandValue: val, PerformedAt: performedAt, }) } }
// add all the new state the user specified for key, vals := range r.Form { if _, ok := actx.Defs[key]; !ok { continue }
for _, val := range vals { labelOps = append(labelOps, models.LabelOp{ Did: did, Rkey: rkey, Subject: syntax.ATURI(subjectUri), Operation: models.LabelOperationAdd, OperandKey: key, OperandValue: val, PerformedAt: performedAt, }) } }
for i := range labelOps { def := actx.Defs[labelOps[i].OperandKey] op := labelOps[i]
// validate permissions: only collaborators can apply labels currently // // TODO: introduce a repo:triage permission ok, err := l.acl.HasRepoPermissionErr(r.Context(), repo, op.Did, "repo:push") if err != nil { fail("Failed to enforce permissions. Please try again later", fmt.Errorf("enforcing permission: %w", err)) return } if !ok { fail("Unauthorized label operation", fmt.Errorf("unauthorized label operation")) return }
// resolve Handle to DID if def.ValueType.IsString() && def.ValueType.IsDidFormat() { val := syntax.AtIdentifier(op.OperandValue) if val.IsHandle() { ident, err := l.dir.Lookup(r.Context(), val) if err != nil { fail(fmt.Sprintf("Failed to resolve handle %q: %s", val, err), err) } op.OperandValue = ident.DID.String() } }
if err := def.ValidateOperandValue(&op); err != nil { fail(fmt.Sprintf("Invalid form data: %s", err), err) return } labelOps[i] = op }
// reduce the opset labelOps = models.ReduceLabelOps(labelOps)
// next, apply all ops introduced in this request and filter out ones that are no-ops validLabelOps := labelOps[:0] for _, op := range labelOps { if err = actx.ApplyLabelOp(labelState, op); err != models.LabelNoOpError { validLabelOps = append(validLabelOps, op) } }
// nothing to do if len(validLabelOps) == 0 { l.pages.HxRefresh(w) return }
// create an atproto record of valid ops record := models.LabelOpsAsRecord(validLabelOps)
client, err := l.oauth.AuthorizedClient(r) if err != nil { fail("Failed to authorize user.", err) return }
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.LabelOpNSID, Repo: did, Rkey: rkey, Record: &lexutil.LexiconTypeDecoder{ Val: &record, }, }) if err != nil { fail("Failed to create record on PDS for user.", err) return } atUri := resp.Uri
tx, err := l.db.BeginTx(r.Context(), nil) if err != nil { fail("Failed to update labels. Try again later.", err) return }
rollback := func() { err1 := tx.Rollback() err2 := rollbackRecord(context.Background(), atUri, client)
// ignore txn complete errors, this is okay if errors.Is(err1, sql.ErrTxDone) { err1 = nil }
if errs := errors.Join(err1, err2); errs != nil { return } } defer rollback()
for _, o := range validLabelOps { if _, err := db.AddLabelOp(tx, &o); err != nil { fail("Failed to update labels. Try again later.", err) return } }
err = tx.Commit() if err != nil { return }
// clear aturi when everything is successful atUri = ""
subject := syntax.ATURI(subjectUri) if subject.Collection() == tangled.RepoIssueNSID { issues, err := db.GetIssues(l.db, orm.FilterEq("at_uri", subjectUri)) if err == nil && len(issues) == 1 { l.notifier.NewIssueLabelOp(r.Context(), syntax.DID(did), &issues[0], validLabelOps) } } if subject.Collection() == tangled.RepoPullNSID { pulls, err := db.GetPulls(l.db, orm.FilterEq("at_uri", subjectUri)) if err == nil && len(pulls) == 1 { l.notifier.NewPullLabelOp(r.Context(), syntax.DID(did), pulls[0], validLabelOps) } }
l.pages.HxRefresh(w)}
// this is used to rollback changes made to the PDS//// it is a no-op if the provided ATURI is emptyfunc rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { if aturi == "" { return nil }
parsed := syntax.ATURI(aturi)
collection := parsed.Collection().String() repo := parsed.Authority().String() rkey := parsed.RecordKey().String()
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ Collection: collection, Repo: repo, Rkey: rkey, }) return err}