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