this repo has no description
1package repo
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 "log/slog"
12 "net/http"
13 "net/url"
14 "path/filepath"
15 "slices"
16 "strconv"
17 "strings"
18 "time"
19
20 "tangled.org/core/api/tangled"
21 "tangled.org/core/appview/commitverify"
22 "tangled.org/core/appview/config"
23 "tangled.org/core/appview/db"
24 "tangled.org/core/appview/models"
25 "tangled.org/core/appview/notify"
26 "tangled.org/core/appview/oauth"
27 "tangled.org/core/appview/pages"
28 "tangled.org/core/appview/pages/markup"
29 "tangled.org/core/appview/reporesolver"
30 "tangled.org/core/appview/validator"
31 xrpcclient "tangled.org/core/appview/xrpcclient"
32 "tangled.org/core/eventconsumer"
33 "tangled.org/core/idresolver"
34 "tangled.org/core/patchutil"
35 "tangled.org/core/rbac"
36 "tangled.org/core/tid"
37 "tangled.org/core/types"
38 "tangled.org/core/xrpc/serviceauth"
39
40 comatproto "github.com/bluesky-social/indigo/api/atproto"
41 atpclient "github.com/bluesky-social/indigo/atproto/client"
42 "github.com/bluesky-social/indigo/atproto/syntax"
43 lexutil "github.com/bluesky-social/indigo/lex/util"
44 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
45 securejoin "github.com/cyphar/filepath-securejoin"
46 "github.com/go-chi/chi/v5"
47 "github.com/go-git/go-git/v5/plumbing"
48)
49
50type Repo struct {
51 repoResolver *reporesolver.RepoResolver
52 idResolver *idresolver.Resolver
53 config *config.Config
54 oauth *oauth.OAuth
55 pages *pages.Pages
56 spindlestream *eventconsumer.Consumer
57 db *db.DB
58 enforcer *rbac.Enforcer
59 notifier notify.Notifier
60 logger *slog.Logger
61 serviceAuth *serviceauth.ServiceAuth
62 validator *validator.Validator
63}
64
65func New(
66 oauth *oauth.OAuth,
67 repoResolver *reporesolver.RepoResolver,
68 pages *pages.Pages,
69 spindlestream *eventconsumer.Consumer,
70 idResolver *idresolver.Resolver,
71 db *db.DB,
72 config *config.Config,
73 notifier notify.Notifier,
74 enforcer *rbac.Enforcer,
75 logger *slog.Logger,
76 validator *validator.Validator,
77) *Repo {
78 return &Repo{oauth: oauth,
79 repoResolver: repoResolver,
80 pages: pages,
81 idResolver: idResolver,
82 config: config,
83 spindlestream: spindlestream,
84 db: db,
85 notifier: notifier,
86 enforcer: enforcer,
87 logger: logger,
88 validator: validator,
89 }
90}
91
92func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
93 ref := chi.URLParam(r, "ref")
94 ref, _ = url.PathUnescape(ref)
95
96 f, err := rp.repoResolver.Resolve(r)
97 if err != nil {
98 log.Println("failed to get repo and knot", err)
99 return
100 }
101
102 scheme := "http"
103 if !rp.config.Core.Dev {
104 scheme = "https"
105 }
106 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
107 xrpcc := &indigoxrpc.Client{
108 Host: host,
109 }
110
111 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
112 archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
113 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
114 log.Println("failed to call XRPC repo.archive", xrpcerr)
115 rp.pages.Error503(w)
116 return
117 }
118
119 // Set headers for file download, just pass along whatever the knot specifies
120 safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
121 filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
122 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
123 w.Header().Set("Content-Type", "application/gzip")
124 w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
125
126 // Write the archive data directly
127 w.Write(archiveBytes)
128}
129
130func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
131 f, err := rp.repoResolver.Resolve(r)
132 if err != nil {
133 log.Println("failed to fully resolve repo", err)
134 return
135 }
136
137 page := 1
138 if r.URL.Query().Get("page") != "" {
139 page, err = strconv.Atoi(r.URL.Query().Get("page"))
140 if err != nil {
141 page = 1
142 }
143 }
144
145 ref := chi.URLParam(r, "ref")
146 ref, _ = url.PathUnescape(ref)
147
148 scheme := "http"
149 if !rp.config.Core.Dev {
150 scheme = "https"
151 }
152 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
153 xrpcc := &indigoxrpc.Client{
154 Host: host,
155 }
156
157 limit := int64(60)
158 cursor := ""
159 if page > 1 {
160 // Convert page number to cursor (offset)
161 offset := (page - 1) * int(limit)
162 cursor = strconv.Itoa(offset)
163 }
164
165 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
166 xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
167 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
168 log.Println("failed to call XRPC repo.log", xrpcerr)
169 rp.pages.Error503(w)
170 return
171 }
172
173 var xrpcResp types.RepoLogResponse
174 if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
175 log.Println("failed to decode XRPC response", err)
176 rp.pages.Error503(w)
177 return
178 }
179
180 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
181 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
182 log.Println("failed to call XRPC repo.tags", xrpcerr)
183 rp.pages.Error503(w)
184 return
185 }
186
187 tagMap := make(map[string][]string)
188 if tagBytes != nil {
189 var tagResp types.RepoTagsResponse
190 if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
191 for _, tag := range tagResp.Tags {
192 tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name)
193 }
194 }
195 }
196
197 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
198 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
199 log.Println("failed to call XRPC repo.branches", xrpcerr)
200 rp.pages.Error503(w)
201 return
202 }
203
204 if branchBytes != nil {
205 var branchResp types.RepoBranchesResponse
206 if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
207 for _, branch := range branchResp.Branches {
208 tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
209 }
210 }
211 }
212
213 user := rp.oauth.GetUser(r)
214
215 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
216 if err != nil {
217 log.Println("failed to fetch email to did mapping", err)
218 }
219
220 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
221 if err != nil {
222 log.Println(err)
223 }
224
225 repoInfo := f.RepoInfo(user)
226
227 var shas []string
228 for _, c := range xrpcResp.Commits {
229 shas = append(shas, c.Hash.String())
230 }
231 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
232 if err != nil {
233 log.Println(err)
234 // non-fatal
235 }
236
237 rp.pages.RepoLog(w, pages.RepoLogParams{
238 LoggedInUser: user,
239 TagMap: tagMap,
240 RepoInfo: repoInfo,
241 RepoLogResponse: xrpcResp,
242 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
243 VerifiedCommits: vc,
244 Pipelines: pipelines,
245 })
246}
247
248func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
249 f, err := rp.repoResolver.Resolve(r)
250 if err != nil {
251 log.Println("failed to get repo and knot", err)
252 w.WriteHeader(http.StatusBadRequest)
253 return
254 }
255
256 user := rp.oauth.GetUser(r)
257 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
258 RepoInfo: f.RepoInfo(user),
259 })
260}
261
262func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
263 f, err := rp.repoResolver.Resolve(r)
264 if err != nil {
265 log.Println("failed to get repo and knot", err)
266 w.WriteHeader(http.StatusBadRequest)
267 return
268 }
269
270 repoAt := f.RepoAt()
271 rkey := repoAt.RecordKey().String()
272 if rkey == "" {
273 log.Println("invalid aturi for repo", err)
274 w.WriteHeader(http.StatusInternalServerError)
275 return
276 }
277
278 user := rp.oauth.GetUser(r)
279
280 switch r.Method {
281 case http.MethodGet:
282 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
283 RepoInfo: f.RepoInfo(user),
284 })
285 return
286 case http.MethodPut:
287 newDescription := r.FormValue("description")
288 client, err := rp.oauth.AuthorizedClient(r)
289 if err != nil {
290 log.Println("failed to get client")
291 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
292 return
293 }
294
295 // optimistic update
296 err = db.UpdateDescription(rp.db, string(repoAt), newDescription)
297 if err != nil {
298 log.Println("failed to perferom update-description query", err)
299 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
300 return
301 }
302
303 newRepo := f.Repo
304 newRepo.Description = newDescription
305 record := newRepo.AsRecord()
306
307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
308 //
309 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
310 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
311 if err != nil {
312 // failed to get record
313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
314 return
315 }
316 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
317 Collection: tangled.RepoNSID,
318 Repo: newRepo.Did,
319 Rkey: newRepo.Rkey,
320 SwapRecord: ex.Cid,
321 Record: &lexutil.LexiconTypeDecoder{
322 Val: &record,
323 },
324 })
325
326 if err != nil {
327 log.Println("failed to perferom update-description query", err)
328 // failed to get record
329 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
330 return
331 }
332
333 newRepoInfo := f.RepoInfo(user)
334 newRepoInfo.Description = newDescription
335
336 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
337 RepoInfo: newRepoInfo,
338 })
339 return
340 }
341}
342
343func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
344 f, err := rp.repoResolver.Resolve(r)
345 if err != nil {
346 log.Println("failed to fully resolve repo", err)
347 return
348 }
349 ref := chi.URLParam(r, "ref")
350 ref, _ = url.PathUnescape(ref)
351
352 var diffOpts types.DiffOpts
353 if d := r.URL.Query().Get("diff"); d == "split" {
354 diffOpts.Split = true
355 }
356
357 if !plumbing.IsHash(ref) {
358 rp.pages.Error404(w)
359 return
360 }
361
362 scheme := "http"
363 if !rp.config.Core.Dev {
364 scheme = "https"
365 }
366 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
367 xrpcc := &indigoxrpc.Client{
368 Host: host,
369 }
370
371 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
372 xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
373 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
374 log.Println("failed to call XRPC repo.diff", xrpcerr)
375 rp.pages.Error503(w)
376 return
377 }
378
379 var result types.RepoCommitResponse
380 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
381 log.Println("failed to decode XRPC response", err)
382 rp.pages.Error503(w)
383 return
384 }
385
386 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
387 if err != nil {
388 log.Println("failed to get email to did mapping:", err)
389 }
390
391 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
392 if err != nil {
393 log.Println(err)
394 }
395
396 user := rp.oauth.GetUser(r)
397 repoInfo := f.RepoInfo(user)
398 pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
399 if err != nil {
400 log.Println(err)
401 // non-fatal
402 }
403 var pipeline *models.Pipeline
404 if p, ok := pipelines[result.Diff.Commit.This]; ok {
405 pipeline = &p
406 }
407
408 rp.pages.RepoCommit(w, pages.RepoCommitParams{
409 LoggedInUser: user,
410 RepoInfo: f.RepoInfo(user),
411 RepoCommitResponse: result,
412 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
413 VerifiedCommit: vc,
414 Pipeline: pipeline,
415 DiffOpts: diffOpts,
416 })
417}
418
419func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
420 f, err := rp.repoResolver.Resolve(r)
421 if err != nil {
422 log.Println("failed to fully resolve repo", err)
423 return
424 }
425
426 ref := chi.URLParam(r, "ref")
427 ref, _ = url.PathUnescape(ref)
428
429 // if the tree path has a trailing slash, let's strip it
430 // so we don't 404
431 treePath := chi.URLParam(r, "*")
432 treePath, _ = url.PathUnescape(treePath)
433 treePath = strings.TrimSuffix(treePath, "/")
434
435 scheme := "http"
436 if !rp.config.Core.Dev {
437 scheme = "https"
438 }
439 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
440 xrpcc := &indigoxrpc.Client{
441 Host: host,
442 }
443
444 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
445 xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
446 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
447 log.Println("failed to call XRPC repo.tree", xrpcerr)
448 rp.pages.Error503(w)
449 return
450 }
451
452 // Convert XRPC response to internal types.RepoTreeResponse
453 files := make([]types.NiceTree, len(xrpcResp.Files))
454 for i, xrpcFile := range xrpcResp.Files {
455 file := types.NiceTree{
456 Name: xrpcFile.Name,
457 Mode: xrpcFile.Mode,
458 Size: int64(xrpcFile.Size),
459 IsFile: xrpcFile.Is_file,
460 IsSubtree: xrpcFile.Is_subtree,
461 }
462
463 // Convert last commit info if present
464 if xrpcFile.Last_commit != nil {
465 commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
466 file.LastCommit = &types.LastCommitInfo{
467 Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
468 Message: xrpcFile.Last_commit.Message,
469 When: commitWhen,
470 }
471 }
472
473 files[i] = file
474 }
475
476 result := types.RepoTreeResponse{
477 Ref: xrpcResp.Ref,
478 Files: files,
479 }
480
481 if xrpcResp.Parent != nil {
482 result.Parent = *xrpcResp.Parent
483 }
484 if xrpcResp.Dotdot != nil {
485 result.DotDot = *xrpcResp.Dotdot
486 }
487 if xrpcResp.Readme != nil {
488 result.ReadmeFileName = xrpcResp.Readme.Filename
489 result.Readme = xrpcResp.Readme.Contents
490 }
491
492 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
493 // so we can safely redirect to the "parent" (which is the same file).
494 if len(result.Files) == 0 && result.Parent == treePath {
495 redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
496 http.Redirect(w, r, redirectTo, http.StatusFound)
497 return
498 }
499
500 user := rp.oauth.GetUser(r)
501
502 var breadcrumbs [][]string
503 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
504 if treePath != "" {
505 for idx, elem := range strings.Split(treePath, "/") {
506 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
507 }
508 }
509
510 sortFiles(result.Files)
511
512 rp.pages.RepoTree(w, pages.RepoTreeParams{
513 LoggedInUser: user,
514 BreadCrumbs: breadcrumbs,
515 TreePath: treePath,
516 RepoInfo: f.RepoInfo(user),
517 RepoTreeResponse: result,
518 })
519}
520
521func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
522 f, err := rp.repoResolver.Resolve(r)
523 if err != nil {
524 log.Println("failed to get repo and knot", err)
525 return
526 }
527
528 scheme := "http"
529 if !rp.config.Core.Dev {
530 scheme = "https"
531 }
532 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
533 xrpcc := &indigoxrpc.Client{
534 Host: host,
535 }
536
537 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
538 xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
539 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
540 log.Println("failed to call XRPC repo.tags", xrpcerr)
541 rp.pages.Error503(w)
542 return
543 }
544
545 var result types.RepoTagsResponse
546 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
547 log.Println("failed to decode XRPC response", err)
548 rp.pages.Error503(w)
549 return
550 }
551
552 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
553 if err != nil {
554 log.Println("failed grab artifacts", err)
555 return
556 }
557
558 // convert artifacts to map for easy UI building
559 artifactMap := make(map[plumbing.Hash][]models.Artifact)
560 for _, a := range artifacts {
561 artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
562 }
563
564 var danglingArtifacts []models.Artifact
565 for _, a := range artifacts {
566 found := false
567 for _, t := range result.Tags {
568 if t.Tag != nil {
569 if t.Tag.Hash == a.Tag {
570 found = true
571 }
572 }
573 }
574
575 if !found {
576 danglingArtifacts = append(danglingArtifacts, a)
577 }
578 }
579
580 user := rp.oauth.GetUser(r)
581 rp.pages.RepoTags(w, pages.RepoTagsParams{
582 LoggedInUser: user,
583 RepoInfo: f.RepoInfo(user),
584 RepoTagsResponse: result,
585 ArtifactMap: artifactMap,
586 DanglingArtifacts: danglingArtifacts,
587 })
588}
589
590func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
591 f, err := rp.repoResolver.Resolve(r)
592 if err != nil {
593 log.Println("failed to get repo and knot", err)
594 return
595 }
596
597 scheme := "http"
598 if !rp.config.Core.Dev {
599 scheme = "https"
600 }
601 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
602 xrpcc := &indigoxrpc.Client{
603 Host: host,
604 }
605
606 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
607 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
608 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
609 log.Println("failed to call XRPC repo.branches", xrpcerr)
610 rp.pages.Error503(w)
611 return
612 }
613
614 var result types.RepoBranchesResponse
615 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
616 log.Println("failed to decode XRPC response", err)
617 rp.pages.Error503(w)
618 return
619 }
620
621 sortBranches(result.Branches)
622
623 user := rp.oauth.GetUser(r)
624 rp.pages.RepoBranches(w, pages.RepoBranchesParams{
625 LoggedInUser: user,
626 RepoInfo: f.RepoInfo(user),
627 RepoBranchesResponse: result,
628 })
629}
630
631func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
632 f, err := rp.repoResolver.Resolve(r)
633 if err != nil {
634 log.Println("failed to get repo and knot", err)
635 return
636 }
637
638 ref := chi.URLParam(r, "ref")
639 ref, _ = url.PathUnescape(ref)
640
641 filePath := chi.URLParam(r, "*")
642 filePath, _ = url.PathUnescape(filePath)
643
644 scheme := "http"
645 if !rp.config.Core.Dev {
646 scheme = "https"
647 }
648 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
649 xrpcc := &indigoxrpc.Client{
650 Host: host,
651 }
652
653 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
654 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
655 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
656 log.Println("failed to call XRPC repo.blob", xrpcerr)
657 rp.pages.Error503(w)
658 return
659 }
660
661 // Use XRPC response directly instead of converting to internal types
662
663 var breadcrumbs [][]string
664 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
665 if filePath != "" {
666 for idx, elem := range strings.Split(filePath, "/") {
667 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
668 }
669 }
670
671 showRendered := false
672 renderToggle := false
673
674 if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
675 renderToggle = true
676 showRendered = r.URL.Query().Get("code") != "true"
677 }
678
679 var unsupported bool
680 var isImage bool
681 var isVideo bool
682 var contentSrc string
683
684 if resp.IsBinary != nil && *resp.IsBinary {
685 ext := strings.ToLower(filepath.Ext(resp.Path))
686 switch ext {
687 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
688 isImage = true
689 case ".mp4", ".webm", ".ogg", ".mov", ".avi":
690 isVideo = true
691 default:
692 unsupported = true
693 }
694
695 // fetch the raw binary content using sh.tangled.repo.blob xrpc
696 repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
697
698 baseURL := &url.URL{
699 Scheme: scheme,
700 Host: f.Knot,
701 Path: "/xrpc/sh.tangled.repo.blob",
702 }
703 query := baseURL.Query()
704 query.Set("repo", repoName)
705 query.Set("ref", ref)
706 query.Set("path", filePath)
707 query.Set("raw", "true")
708 baseURL.RawQuery = query.Encode()
709 blobURL := baseURL.String()
710
711 contentSrc = blobURL
712 if !rp.config.Core.Dev {
713 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
714 }
715 }
716
717 lines := 0
718 if resp.IsBinary == nil || !*resp.IsBinary {
719 lines = strings.Count(resp.Content, "\n") + 1
720 }
721
722 var sizeHint uint64
723 if resp.Size != nil {
724 sizeHint = uint64(*resp.Size)
725 } else {
726 sizeHint = uint64(len(resp.Content))
727 }
728
729 user := rp.oauth.GetUser(r)
730
731 // Determine if content is binary (dereference pointer)
732 isBinary := false
733 if resp.IsBinary != nil {
734 isBinary = *resp.IsBinary
735 }
736
737 rp.pages.RepoBlob(w, pages.RepoBlobParams{
738 LoggedInUser: user,
739 RepoInfo: f.RepoInfo(user),
740 BreadCrumbs: breadcrumbs,
741 ShowRendered: showRendered,
742 RenderToggle: renderToggle,
743 Unsupported: unsupported,
744 IsImage: isImage,
745 IsVideo: isVideo,
746 ContentSrc: contentSrc,
747 RepoBlob_Output: resp,
748 Contents: resp.Content,
749 Lines: lines,
750 SizeHint: sizeHint,
751 IsBinary: isBinary,
752 })
753}
754
755func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
756 f, err := rp.repoResolver.Resolve(r)
757 if err != nil {
758 log.Println("failed to get repo and knot", err)
759 w.WriteHeader(http.StatusBadRequest)
760 return
761 }
762
763 ref := chi.URLParam(r, "ref")
764 ref, _ = url.PathUnescape(ref)
765
766 filePath := chi.URLParam(r, "*")
767 filePath, _ = url.PathUnescape(filePath)
768
769 scheme := "http"
770 if !rp.config.Core.Dev {
771 scheme = "https"
772 }
773
774 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
775 baseURL := &url.URL{
776 Scheme: scheme,
777 Host: f.Knot,
778 Path: "/xrpc/sh.tangled.repo.blob",
779 }
780 query := baseURL.Query()
781 query.Set("repo", repo)
782 query.Set("ref", ref)
783 query.Set("path", filePath)
784 query.Set("raw", "true")
785 baseURL.RawQuery = query.Encode()
786 blobURL := baseURL.String()
787
788 req, err := http.NewRequest("GET", blobURL, nil)
789 if err != nil {
790 log.Println("failed to create request", err)
791 return
792 }
793
794 // forward the If-None-Match header
795 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
796 req.Header.Set("If-None-Match", clientETag)
797 }
798
799 client := &http.Client{}
800 resp, err := client.Do(req)
801 if err != nil {
802 log.Println("failed to reach knotserver", err)
803 rp.pages.Error503(w)
804 return
805 }
806 defer resp.Body.Close()
807
808 // forward 304 not modified
809 if resp.StatusCode == http.StatusNotModified {
810 w.WriteHeader(http.StatusNotModified)
811 return
812 }
813
814 if resp.StatusCode != http.StatusOK {
815 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
816 w.WriteHeader(resp.StatusCode)
817 _, _ = io.Copy(w, resp.Body)
818 return
819 }
820
821 contentType := resp.Header.Get("Content-Type")
822 body, err := io.ReadAll(resp.Body)
823 if err != nil {
824 log.Printf("error reading response body from knotserver: %v", err)
825 w.WriteHeader(http.StatusInternalServerError)
826 return
827 }
828
829 if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
830 // serve all textual content as text/plain
831 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
832 w.Write(body)
833 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
834 // serve images and videos with their original content type
835 w.Header().Set("Content-Type", contentType)
836 w.Write(body)
837 } else {
838 w.WriteHeader(http.StatusUnsupportedMediaType)
839 w.Write([]byte("unsupported content type"))
840 return
841 }
842}
843
844// isTextualMimeType returns true if the MIME type represents textual content
845// that should be served as text/plain
846func isTextualMimeType(mimeType string) bool {
847 textualTypes := []string{
848 "application/json",
849 "application/xml",
850 "application/yaml",
851 "application/x-yaml",
852 "application/toml",
853 "application/javascript",
854 "application/ecmascript",
855 "message/",
856 }
857
858 return slices.Contains(textualTypes, mimeType)
859}
860
861// modify the spindle configured for this repo
862func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
863 user := rp.oauth.GetUser(r)
864 l := rp.logger.With("handler", "EditSpindle")
865 l = l.With("did", user.Did)
866
867 errorId := "operation-error"
868 fail := func(msg string, err error) {
869 l.Error(msg, "err", err)
870 rp.pages.Notice(w, errorId, msg)
871 }
872
873 f, err := rp.repoResolver.Resolve(r)
874 if err != nil {
875 fail("Failed to resolve repo. Try again later", err)
876 return
877 }
878
879 newSpindle := r.FormValue("spindle")
880 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
881 client, err := rp.oauth.AuthorizedClient(r)
882 if err != nil {
883 fail("Failed to authorize. Try again later.", err)
884 return
885 }
886
887 if !removingSpindle {
888 // ensure that this is a valid spindle for this user
889 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
890 if err != nil {
891 fail("Failed to find spindles. Try again later.", err)
892 return
893 }
894
895 if !slices.Contains(validSpindles, newSpindle) {
896 fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
897 return
898 }
899 }
900
901 newRepo := f.Repo
902 newRepo.Spindle = newSpindle
903 record := newRepo.AsRecord()
904
905 spindlePtr := &newSpindle
906 if removingSpindle {
907 spindlePtr = nil
908 newRepo.Spindle = ""
909 }
910
911 // optimistic update
912 err = db.UpdateSpindle(rp.db, newRepo.RepoAt().String(), spindlePtr)
913 if err != nil {
914 fail("Failed to update spindle. Try again later.", err)
915 return
916 }
917
918 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
919 if err != nil {
920 fail("Failed to update spindle, no record found on PDS.", err)
921 return
922 }
923 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
924 Collection: tangled.RepoNSID,
925 Repo: newRepo.Did,
926 Rkey: newRepo.Rkey,
927 SwapRecord: ex.Cid,
928 Record: &lexutil.LexiconTypeDecoder{
929 Val: &record,
930 },
931 })
932
933 if err != nil {
934 fail("Failed to update spindle, unable to save to PDS.", err)
935 return
936 }
937
938 if !removingSpindle {
939 // add this spindle to spindle stream
940 rp.spindlestream.AddSource(
941 context.Background(),
942 eventconsumer.NewSpindleSource(newSpindle),
943 )
944 }
945
946 rp.pages.HxRefresh(w)
947}
948
949func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) {
950 user := rp.oauth.GetUser(r)
951 l := rp.logger.With("handler", "AddLabel")
952 l = l.With("did", user.Did)
953
954 f, err := rp.repoResolver.Resolve(r)
955 if err != nil {
956 l.Error("failed to get repo and knot", "err", err)
957 return
958 }
959
960 errorId := "add-label-error"
961 fail := func(msg string, err error) {
962 l.Error(msg, "err", err)
963 rp.pages.Notice(w, errorId, msg)
964 }
965
966 // get form values for label definition
967 name := r.FormValue("name")
968 concreteType := r.FormValue("valueType")
969 valueFormat := r.FormValue("valueFormat")
970 enumValues := r.FormValue("enumValues")
971 scope := r.Form["scope"]
972 color := r.FormValue("color")
973 multiple := r.FormValue("multiple") == "true"
974
975 var variants []string
976 for part := range strings.SplitSeq(enumValues, ",") {
977 if part = strings.TrimSpace(part); part != "" {
978 variants = append(variants, part)
979 }
980 }
981
982 if concreteType == "" {
983 concreteType = "null"
984 }
985
986 format := models.ValueTypeFormatAny
987 if valueFormat == "did" {
988 format = models.ValueTypeFormatDid
989 }
990
991 valueType := models.ValueType{
992 Type: models.ConcreteType(concreteType),
993 Format: format,
994 Enum: variants,
995 }
996
997 label := models.LabelDefinition{
998 Did: user.Did,
999 Rkey: tid.TID(),
1000 Name: name,
1001 ValueType: valueType,
1002 Scope: scope,
1003 Color: &color,
1004 Multiple: multiple,
1005 Created: time.Now(),
1006 }
1007 if err := rp.validator.ValidateLabelDefinition(&label); err != nil {
1008 fail(err.Error(), err)
1009 return
1010 }
1011
1012 // announce this relation into the firehose, store into owners' pds
1013 client, err := rp.oauth.AuthorizedClient(r)
1014 if err != nil {
1015 fail(err.Error(), err)
1016 return
1017 }
1018
1019 // emit a labelRecord
1020 labelRecord := label.AsRecord()
1021 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1022 Collection: tangled.LabelDefinitionNSID,
1023 Repo: label.Did,
1024 Rkey: label.Rkey,
1025 Record: &lexutil.LexiconTypeDecoder{
1026 Val: &labelRecord,
1027 },
1028 })
1029 // invalid record
1030 if err != nil {
1031 fail("Failed to write record to PDS.", err)
1032 return
1033 }
1034
1035 aturi := resp.Uri
1036 l = l.With("at-uri", aturi)
1037 l.Info("wrote label record to PDS")
1038
1039 // update the repo to subscribe to this label
1040 newRepo := f.Repo
1041 newRepo.Labels = append(newRepo.Labels, aturi)
1042 repoRecord := newRepo.AsRecord()
1043
1044 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1045 if err != nil {
1046 fail("Failed to update labels, no record found on PDS.", err)
1047 return
1048 }
1049 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1050 Collection: tangled.RepoNSID,
1051 Repo: newRepo.Did,
1052 Rkey: newRepo.Rkey,
1053 SwapRecord: ex.Cid,
1054 Record: &lexutil.LexiconTypeDecoder{
1055 Val: &repoRecord,
1056 },
1057 })
1058 if err != nil {
1059 fail("Failed to update labels for repo.", err)
1060 return
1061 }
1062
1063 tx, err := rp.db.BeginTx(r.Context(), nil)
1064 if err != nil {
1065 fail("Failed to add label.", err)
1066 return
1067 }
1068
1069 rollback := func() {
1070 err1 := tx.Rollback()
1071 err2 := rollbackRecord(context.Background(), aturi, client)
1072
1073 // ignore txn complete errors, this is okay
1074 if errors.Is(err1, sql.ErrTxDone) {
1075 err1 = nil
1076 }
1077
1078 if errs := errors.Join(err1, err2); errs != nil {
1079 l.Error("failed to rollback changes", "errs", errs)
1080 return
1081 }
1082 }
1083 defer rollback()
1084
1085 _, err = db.AddLabelDefinition(tx, &label)
1086 if err != nil {
1087 fail("Failed to add label.", err)
1088 return
1089 }
1090
1091 err = db.SubscribeLabel(tx, &models.RepoLabel{
1092 RepoAt: f.RepoAt(),
1093 LabelAt: label.AtUri(),
1094 })
1095
1096 err = tx.Commit()
1097 if err != nil {
1098 fail("Failed to add label.", err)
1099 return
1100 }
1101
1102 // clear aturi when everything is successful
1103 aturi = ""
1104
1105 rp.pages.HxRefresh(w)
1106}
1107
1108func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) {
1109 user := rp.oauth.GetUser(r)
1110 l := rp.logger.With("handler", "DeleteLabel")
1111 l = l.With("did", user.Did)
1112
1113 f, err := rp.repoResolver.Resolve(r)
1114 if err != nil {
1115 l.Error("failed to get repo and knot", "err", err)
1116 return
1117 }
1118
1119 errorId := "label-operation"
1120 fail := func(msg string, err error) {
1121 l.Error(msg, "err", err)
1122 rp.pages.Notice(w, errorId, msg)
1123 }
1124
1125 // get form values
1126 labelId := r.FormValue("label-id")
1127
1128 label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId))
1129 if err != nil {
1130 fail("Failed to find label definition.", err)
1131 return
1132 }
1133
1134 client, err := rp.oauth.AuthorizedClient(r)
1135 if err != nil {
1136 fail(err.Error(), err)
1137 return
1138 }
1139
1140 // delete label record from PDS
1141 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
1142 Collection: tangled.LabelDefinitionNSID,
1143 Repo: label.Did,
1144 Rkey: label.Rkey,
1145 })
1146 if err != nil {
1147 fail("Failed to delete label record from PDS.", err)
1148 return
1149 }
1150
1151 // update repo record to remove the label reference
1152 newRepo := f.Repo
1153 var updated []string
1154 removedAt := label.AtUri().String()
1155 for _, l := range newRepo.Labels {
1156 if l != removedAt {
1157 updated = append(updated, l)
1158 }
1159 }
1160 newRepo.Labels = updated
1161 repoRecord := newRepo.AsRecord()
1162
1163 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1164 if err != nil {
1165 fail("Failed to update labels, no record found on PDS.", err)
1166 return
1167 }
1168 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1169 Collection: tangled.RepoNSID,
1170 Repo: newRepo.Did,
1171 Rkey: newRepo.Rkey,
1172 SwapRecord: ex.Cid,
1173 Record: &lexutil.LexiconTypeDecoder{
1174 Val: &repoRecord,
1175 },
1176 })
1177 if err != nil {
1178 fail("Failed to update repo record.", err)
1179 return
1180 }
1181
1182 // transaction for DB changes
1183 tx, err := rp.db.BeginTx(r.Context(), nil)
1184 if err != nil {
1185 fail("Failed to delete label.", err)
1186 return
1187 }
1188 defer tx.Rollback()
1189
1190 err = db.UnsubscribeLabel(
1191 tx,
1192 db.FilterEq("repo_at", f.RepoAt()),
1193 db.FilterEq("label_at", removedAt),
1194 )
1195 if err != nil {
1196 fail("Failed to unsubscribe label.", err)
1197 return
1198 }
1199
1200 err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id))
1201 if err != nil {
1202 fail("Failed to delete label definition.", err)
1203 return
1204 }
1205
1206 err = tx.Commit()
1207 if err != nil {
1208 fail("Failed to delete label.", err)
1209 return
1210 }
1211
1212 // everything succeeded
1213 rp.pages.HxRefresh(w)
1214}
1215
1216func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) {
1217 user := rp.oauth.GetUser(r)
1218 l := rp.logger.With("handler", "SubscribeLabel")
1219 l = l.With("did", user.Did)
1220
1221 f, err := rp.repoResolver.Resolve(r)
1222 if err != nil {
1223 l.Error("failed to get repo and knot", "err", err)
1224 return
1225 }
1226
1227 if err := r.ParseForm(); err != nil {
1228 l.Error("invalid form", "err", err)
1229 return
1230 }
1231
1232 errorId := "default-label-operation"
1233 fail := func(msg string, err error) {
1234 l.Error(msg, "err", err)
1235 rp.pages.Notice(w, errorId, msg)
1236 }
1237
1238 labelAts := r.Form["label"]
1239 _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
1240 if err != nil {
1241 fail("Failed to subscribe to label.", err)
1242 return
1243 }
1244
1245 newRepo := f.Repo
1246 newRepo.Labels = append(newRepo.Labels, labelAts...)
1247
1248 // dedup
1249 slices.Sort(newRepo.Labels)
1250 newRepo.Labels = slices.Compact(newRepo.Labels)
1251
1252 repoRecord := newRepo.AsRecord()
1253
1254 client, err := rp.oauth.AuthorizedClient(r)
1255 if err != nil {
1256 fail(err.Error(), err)
1257 return
1258 }
1259
1260 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1261 if err != nil {
1262 fail("Failed to update labels, no record found on PDS.", err)
1263 return
1264 }
1265 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1266 Collection: tangled.RepoNSID,
1267 Repo: newRepo.Did,
1268 Rkey: newRepo.Rkey,
1269 SwapRecord: ex.Cid,
1270 Record: &lexutil.LexiconTypeDecoder{
1271 Val: &repoRecord,
1272 },
1273 })
1274
1275 tx, err := rp.db.Begin()
1276 if err != nil {
1277 fail("Failed to subscribe to label.", err)
1278 return
1279 }
1280 defer tx.Rollback()
1281
1282 for _, l := range labelAts {
1283 err = db.SubscribeLabel(tx, &models.RepoLabel{
1284 RepoAt: f.RepoAt(),
1285 LabelAt: syntax.ATURI(l),
1286 })
1287 if err != nil {
1288 fail("Failed to subscribe to label.", err)
1289 return
1290 }
1291 }
1292
1293 if err := tx.Commit(); err != nil {
1294 fail("Failed to subscribe to label.", err)
1295 return
1296 }
1297
1298 // everything succeeded
1299 rp.pages.HxRefresh(w)
1300}
1301
1302func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) {
1303 user := rp.oauth.GetUser(r)
1304 l := rp.logger.With("handler", "UnsubscribeLabel")
1305 l = l.With("did", user.Did)
1306
1307 f, err := rp.repoResolver.Resolve(r)
1308 if err != nil {
1309 l.Error("failed to get repo and knot", "err", err)
1310 return
1311 }
1312
1313 if err := r.ParseForm(); err != nil {
1314 l.Error("invalid form", "err", err)
1315 return
1316 }
1317
1318 errorId := "default-label-operation"
1319 fail := func(msg string, err error) {
1320 l.Error(msg, "err", err)
1321 rp.pages.Notice(w, errorId, msg)
1322 }
1323
1324 labelAts := r.Form["label"]
1325 _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
1326 if err != nil {
1327 fail("Failed to unsubscribe to label.", err)
1328 return
1329 }
1330
1331 // update repo record to remove the label reference
1332 newRepo := f.Repo
1333 var updated []string
1334 for _, l := range newRepo.Labels {
1335 if !slices.Contains(labelAts, l) {
1336 updated = append(updated, l)
1337 }
1338 }
1339 newRepo.Labels = updated
1340 repoRecord := newRepo.AsRecord()
1341
1342 client, err := rp.oauth.AuthorizedClient(r)
1343 if err != nil {
1344 fail(err.Error(), err)
1345 return
1346 }
1347
1348 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1349 if err != nil {
1350 fail("Failed to update labels, no record found on PDS.", err)
1351 return
1352 }
1353 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1354 Collection: tangled.RepoNSID,
1355 Repo: newRepo.Did,
1356 Rkey: newRepo.Rkey,
1357 SwapRecord: ex.Cid,
1358 Record: &lexutil.LexiconTypeDecoder{
1359 Val: &repoRecord,
1360 },
1361 })
1362
1363 err = db.UnsubscribeLabel(
1364 rp.db,
1365 db.FilterEq("repo_at", f.RepoAt()),
1366 db.FilterIn("label_at", labelAts),
1367 )
1368 if err != nil {
1369 fail("Failed to unsubscribe label.", err)
1370 return
1371 }
1372
1373 // everything succeeded
1374 rp.pages.HxRefresh(w)
1375}
1376
1377func (rp *Repo) LabelPanel(w http.ResponseWriter, r *http.Request) {
1378 l := rp.logger.With("handler", "LabelPanel")
1379
1380 f, err := rp.repoResolver.Resolve(r)
1381 if err != nil {
1382 l.Error("failed to get repo and knot", "err", err)
1383 return
1384 }
1385
1386 subjectStr := r.FormValue("subject")
1387 subject, err := syntax.ParseATURI(subjectStr)
1388 if err != nil {
1389 l.Error("failed to get repo and knot", "err", err)
1390 return
1391 }
1392
1393 labelDefs, err := db.GetLabelDefinitions(
1394 rp.db,
1395 db.FilterIn("at_uri", f.Repo.Labels),
1396 db.FilterContains("scope", subject.Collection().String()),
1397 )
1398 if err != nil {
1399 log.Println("failed to fetch label defs", err)
1400 return
1401 }
1402
1403 defs := make(map[string]*models.LabelDefinition)
1404 for _, l := range labelDefs {
1405 defs[l.AtUri().String()] = &l
1406 }
1407
1408 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1409 if err != nil {
1410 log.Println("failed to build label state", err)
1411 return
1412 }
1413 state := states[subject]
1414
1415 user := rp.oauth.GetUser(r)
1416 rp.pages.LabelPanel(w, pages.LabelPanelParams{
1417 LoggedInUser: user,
1418 RepoInfo: f.RepoInfo(user),
1419 Defs: defs,
1420 Subject: subject.String(),
1421 State: state,
1422 })
1423}
1424
1425func (rp *Repo) EditLabelPanel(w http.ResponseWriter, r *http.Request) {
1426 l := rp.logger.With("handler", "EditLabelPanel")
1427
1428 f, err := rp.repoResolver.Resolve(r)
1429 if err != nil {
1430 l.Error("failed to get repo and knot", "err", err)
1431 return
1432 }
1433
1434 subjectStr := r.FormValue("subject")
1435 subject, err := syntax.ParseATURI(subjectStr)
1436 if err != nil {
1437 l.Error("failed to get repo and knot", "err", err)
1438 return
1439 }
1440
1441 labelDefs, err := db.GetLabelDefinitions(
1442 rp.db,
1443 db.FilterIn("at_uri", f.Repo.Labels),
1444 db.FilterContains("scope", subject.Collection().String()),
1445 )
1446 if err != nil {
1447 log.Println("failed to fetch labels", err)
1448 return
1449 }
1450
1451 defs := make(map[string]*models.LabelDefinition)
1452 for _, l := range labelDefs {
1453 defs[l.AtUri().String()] = &l
1454 }
1455
1456 states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1457 if err != nil {
1458 log.Println("failed to build label state", err)
1459 return
1460 }
1461 state := states[subject]
1462
1463 user := rp.oauth.GetUser(r)
1464 rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{
1465 LoggedInUser: user,
1466 RepoInfo: f.RepoInfo(user),
1467 Defs: defs,
1468 Subject: subject.String(),
1469 State: state,
1470 })
1471}
1472
1473func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
1474 user := rp.oauth.GetUser(r)
1475 l := rp.logger.With("handler", "AddCollaborator")
1476 l = l.With("did", user.Did)
1477
1478 f, err := rp.repoResolver.Resolve(r)
1479 if err != nil {
1480 l.Error("failed to get repo and knot", "err", err)
1481 return
1482 }
1483
1484 errorId := "add-collaborator-error"
1485 fail := func(msg string, err error) {
1486 l.Error(msg, "err", err)
1487 rp.pages.Notice(w, errorId, msg)
1488 }
1489
1490 collaborator := r.FormValue("collaborator")
1491 if collaborator == "" {
1492 fail("Invalid form.", nil)
1493 return
1494 }
1495
1496 // remove a single leading `@`, to make @handle work with ResolveIdent
1497 collaborator = strings.TrimPrefix(collaborator, "@")
1498
1499 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
1500 if err != nil {
1501 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
1502 return
1503 }
1504
1505 if collaboratorIdent.DID.String() == user.Did {
1506 fail("You seem to be adding yourself as a collaborator.", nil)
1507 return
1508 }
1509 l = l.With("collaborator", collaboratorIdent.Handle)
1510 l = l.With("knot", f.Knot)
1511
1512 // announce this relation into the firehose, store into owners' pds
1513 client, err := rp.oauth.AuthorizedClient(r)
1514 if err != nil {
1515 fail("Failed to write to PDS.", err)
1516 return
1517 }
1518
1519 // emit a record
1520 currentUser := rp.oauth.GetUser(r)
1521 rkey := tid.TID()
1522 createdAt := time.Now()
1523 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1524 Collection: tangled.RepoCollaboratorNSID,
1525 Repo: currentUser.Did,
1526 Rkey: rkey,
1527 Record: &lexutil.LexiconTypeDecoder{
1528 Val: &tangled.RepoCollaborator{
1529 Subject: collaboratorIdent.DID.String(),
1530 Repo: string(f.RepoAt()),
1531 CreatedAt: createdAt.Format(time.RFC3339),
1532 }},
1533 })
1534 // invalid record
1535 if err != nil {
1536 fail("Failed to write record to PDS.", err)
1537 return
1538 }
1539
1540 aturi := resp.Uri
1541 l = l.With("at-uri", aturi)
1542 l.Info("wrote record to PDS")
1543
1544 tx, err := rp.db.BeginTx(r.Context(), nil)
1545 if err != nil {
1546 fail("Failed to add collaborator.", err)
1547 return
1548 }
1549
1550 rollback := func() {
1551 err1 := tx.Rollback()
1552 err2 := rp.enforcer.E.LoadPolicy()
1553 err3 := rollbackRecord(context.Background(), aturi, client)
1554
1555 // ignore txn complete errors, this is okay
1556 if errors.Is(err1, sql.ErrTxDone) {
1557 err1 = nil
1558 }
1559
1560 if errs := errors.Join(err1, err2, err3); errs != nil {
1561 l.Error("failed to rollback changes", "errs", errs)
1562 return
1563 }
1564 }
1565 defer rollback()
1566
1567 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
1568 if err != nil {
1569 fail("Failed to add collaborator permissions.", err)
1570 return
1571 }
1572
1573 err = db.AddCollaborator(tx, models.Collaborator{
1574 Did: syntax.DID(currentUser.Did),
1575 Rkey: rkey,
1576 SubjectDid: collaboratorIdent.DID,
1577 RepoAt: f.RepoAt(),
1578 Created: createdAt,
1579 })
1580 if err != nil {
1581 fail("Failed to add collaborator.", err)
1582 return
1583 }
1584
1585 err = tx.Commit()
1586 if err != nil {
1587 fail("Failed to add collaborator.", err)
1588 return
1589 }
1590
1591 err = rp.enforcer.E.SavePolicy()
1592 if err != nil {
1593 fail("Failed to update collaborator permissions.", err)
1594 return
1595 }
1596
1597 // clear aturi to when everything is successful
1598 aturi = ""
1599
1600 rp.pages.HxRefresh(w)
1601}
1602
1603func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
1604 user := rp.oauth.GetUser(r)
1605
1606 noticeId := "operation-error"
1607 f, err := rp.repoResolver.Resolve(r)
1608 if err != nil {
1609 log.Println("failed to get repo and knot", err)
1610 return
1611 }
1612
1613 // remove record from pds
1614 atpClient, err := rp.oauth.AuthorizedClient(r)
1615 if err != nil {
1616 log.Println("failed to get authorized client", err)
1617 return
1618 }
1619 _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{
1620 Collection: tangled.RepoNSID,
1621 Repo: user.Did,
1622 Rkey: f.Rkey,
1623 })
1624 if err != nil {
1625 log.Printf("failed to delete record: %s", err)
1626 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
1627 return
1628 }
1629 log.Println("removed repo record ", f.RepoAt().String())
1630
1631 client, err := rp.oauth.ServiceClient(
1632 r,
1633 oauth.WithService(f.Knot),
1634 oauth.WithLxm(tangled.RepoDeleteNSID),
1635 oauth.WithDev(rp.config.Core.Dev),
1636 )
1637 if err != nil {
1638 log.Println("failed to connect to knot server:", err)
1639 return
1640 }
1641
1642 err = tangled.RepoDelete(
1643 r.Context(),
1644 client,
1645 &tangled.RepoDelete_Input{
1646 Did: f.OwnerDid(),
1647 Name: f.Name,
1648 Rkey: f.Rkey,
1649 },
1650 )
1651 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1652 rp.pages.Notice(w, noticeId, err.Error())
1653 return
1654 }
1655 log.Println("deleted repo from knot")
1656
1657 tx, err := rp.db.BeginTx(r.Context(), nil)
1658 if err != nil {
1659 log.Println("failed to start tx")
1660 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
1661 return
1662 }
1663 defer func() {
1664 tx.Rollback()
1665 err = rp.enforcer.E.LoadPolicy()
1666 if err != nil {
1667 log.Println("failed to rollback policies")
1668 }
1669 }()
1670
1671 // remove collaborator RBAC
1672 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
1673 if err != nil {
1674 rp.pages.Notice(w, noticeId, "Failed to remove collaborators")
1675 return
1676 }
1677 for _, c := range repoCollaborators {
1678 did := c[0]
1679 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
1680 }
1681 log.Println("removed collaborators")
1682
1683 // remove repo RBAC
1684 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
1685 if err != nil {
1686 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
1687 return
1688 }
1689
1690 // remove repo from db
1691 err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
1692 if err != nil {
1693 rp.pages.Notice(w, noticeId, "Failed to update appview")
1694 return
1695 }
1696 log.Println("removed repo from db")
1697
1698 err = tx.Commit()
1699 if err != nil {
1700 log.Println("failed to commit changes", err)
1701 http.Error(w, err.Error(), http.StatusInternalServerError)
1702 return
1703 }
1704
1705 err = rp.enforcer.E.SavePolicy()
1706 if err != nil {
1707 log.Println("failed to update ACLs", err)
1708 http.Error(w, err.Error(), http.StatusInternalServerError)
1709 return
1710 }
1711
1712 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1713}
1714
1715func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1716 f, err := rp.repoResolver.Resolve(r)
1717 if err != nil {
1718 log.Println("failed to get repo and knot", err)
1719 return
1720 }
1721
1722 noticeId := "operation-error"
1723 branch := r.FormValue("branch")
1724 if branch == "" {
1725 http.Error(w, "malformed form", http.StatusBadRequest)
1726 return
1727 }
1728
1729 client, err := rp.oauth.ServiceClient(
1730 r,
1731 oauth.WithService(f.Knot),
1732 oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
1733 oauth.WithDev(rp.config.Core.Dev),
1734 )
1735 if err != nil {
1736 log.Println("failed to connect to knot server:", err)
1737 rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1738 return
1739 }
1740
1741 xe := tangled.RepoSetDefaultBranch(
1742 r.Context(),
1743 client,
1744 &tangled.RepoSetDefaultBranch_Input{
1745 Repo: f.RepoAt().String(),
1746 DefaultBranch: branch,
1747 },
1748 )
1749 if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1750 log.Println("xrpc failed", "err", xe)
1751 rp.pages.Notice(w, noticeId, err.Error())
1752 return
1753 }
1754
1755 rp.pages.HxRefresh(w)
1756}
1757
1758func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1759 user := rp.oauth.GetUser(r)
1760 l := rp.logger.With("handler", "Secrets")
1761 l = l.With("did", user.Did)
1762
1763 f, err := rp.repoResolver.Resolve(r)
1764 if err != nil {
1765 log.Println("failed to get repo and knot", err)
1766 return
1767 }
1768
1769 if f.Spindle == "" {
1770 log.Println("empty spindle cannot add/rm secret", err)
1771 return
1772 }
1773
1774 lxm := tangled.RepoAddSecretNSID
1775 if r.Method == http.MethodDelete {
1776 lxm = tangled.RepoRemoveSecretNSID
1777 }
1778
1779 spindleClient, err := rp.oauth.ServiceClient(
1780 r,
1781 oauth.WithService(f.Spindle),
1782 oauth.WithLxm(lxm),
1783 oauth.WithExp(60),
1784 oauth.WithDev(rp.config.Core.Dev),
1785 )
1786 if err != nil {
1787 log.Println("failed to create spindle client", err)
1788 return
1789 }
1790
1791 key := r.FormValue("key")
1792 if key == "" {
1793 w.WriteHeader(http.StatusBadRequest)
1794 return
1795 }
1796
1797 switch r.Method {
1798 case http.MethodPut:
1799 errorId := "add-secret-error"
1800
1801 value := r.FormValue("value")
1802 if value == "" {
1803 w.WriteHeader(http.StatusBadRequest)
1804 return
1805 }
1806
1807 err = tangled.RepoAddSecret(
1808 r.Context(),
1809 spindleClient,
1810 &tangled.RepoAddSecret_Input{
1811 Repo: f.RepoAt().String(),
1812 Key: key,
1813 Value: value,
1814 },
1815 )
1816 if err != nil {
1817 l.Error("Failed to add secret.", "err", err)
1818 rp.pages.Notice(w, errorId, "Failed to add secret.")
1819 return
1820 }
1821
1822 case http.MethodDelete:
1823 errorId := "operation-error"
1824
1825 err = tangled.RepoRemoveSecret(
1826 r.Context(),
1827 spindleClient,
1828 &tangled.RepoRemoveSecret_Input{
1829 Repo: f.RepoAt().String(),
1830 Key: key,
1831 },
1832 )
1833 if err != nil {
1834 l.Error("Failed to delete secret.", "err", err)
1835 rp.pages.Notice(w, errorId, "Failed to delete secret.")
1836 return
1837 }
1838 }
1839
1840 rp.pages.HxRefresh(w)
1841}
1842
1843type tab = map[string]any
1844
1845var (
1846 // would be great to have ordered maps right about now
1847 settingsTabs []tab = []tab{
1848 {"Name": "general", "Icon": "sliders-horizontal"},
1849 {"Name": "access", "Icon": "users"},
1850 {"Name": "pipelines", "Icon": "layers-2"},
1851 }
1852)
1853
1854func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1855 tabVal := r.URL.Query().Get("tab")
1856 if tabVal == "" {
1857 tabVal = "general"
1858 }
1859
1860 switch tabVal {
1861 case "general":
1862 rp.generalSettings(w, r)
1863
1864 case "access":
1865 rp.accessSettings(w, r)
1866
1867 case "pipelines":
1868 rp.pipelineSettings(w, r)
1869 }
1870}
1871
1872func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1873 f, err := rp.repoResolver.Resolve(r)
1874 user := rp.oauth.GetUser(r)
1875
1876 scheme := "http"
1877 if !rp.config.Core.Dev {
1878 scheme = "https"
1879 }
1880 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1881 xrpcc := &indigoxrpc.Client{
1882 Host: host,
1883 }
1884
1885 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1886 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1887 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1888 log.Println("failed to call XRPC repo.branches", xrpcerr)
1889 rp.pages.Error503(w)
1890 return
1891 }
1892
1893 var result types.RepoBranchesResponse
1894 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1895 log.Println("failed to decode XRPC response", err)
1896 rp.pages.Error503(w)
1897 return
1898 }
1899
1900 defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs()))
1901 if err != nil {
1902 log.Println("failed to fetch labels", err)
1903 rp.pages.Error503(w)
1904 return
1905 }
1906
1907 labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
1908 if err != nil {
1909 log.Println("failed to fetch labels", err)
1910 rp.pages.Error503(w)
1911 return
1912 }
1913 // remove default labels from the labels list, if present
1914 defaultLabelMap := make(map[string]bool)
1915 for _, dl := range defaultLabels {
1916 defaultLabelMap[dl.AtUri().String()] = true
1917 }
1918 n := 0
1919 for _, l := range labels {
1920 if !defaultLabelMap[l.AtUri().String()] {
1921 labels[n] = l
1922 n++
1923 }
1924 }
1925 labels = labels[:n]
1926
1927 subscribedLabels := make(map[string]struct{})
1928 for _, l := range f.Repo.Labels {
1929 subscribedLabels[l] = struct{}{}
1930 }
1931
1932 // if there is atleast 1 unsubbed default label, show the "subscribe all" button,
1933 // if all default labels are subbed, show the "unsubscribe all" button
1934 shouldSubscribeAll := false
1935 for _, dl := range defaultLabels {
1936 if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
1937 // one of the default labels is not subscribed to
1938 shouldSubscribeAll = true
1939 break
1940 }
1941 }
1942
1943 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1944 LoggedInUser: user,
1945 RepoInfo: f.RepoInfo(user),
1946 Branches: result.Branches,
1947 Labels: labels,
1948 DefaultLabels: defaultLabels,
1949 SubscribedLabels: subscribedLabels,
1950 ShouldSubscribeAll: shouldSubscribeAll,
1951 Tabs: settingsTabs,
1952 Tab: "general",
1953 })
1954}
1955
1956func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
1957 f, err := rp.repoResolver.Resolve(r)
1958 user := rp.oauth.GetUser(r)
1959
1960 repoCollaborators, err := f.Collaborators(r.Context())
1961 if err != nil {
1962 log.Println("failed to get collaborators", err)
1963 }
1964
1965 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
1966 LoggedInUser: user,
1967 RepoInfo: f.RepoInfo(user),
1968 Tabs: settingsTabs,
1969 Tab: "access",
1970 Collaborators: repoCollaborators,
1971 })
1972}
1973
1974func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
1975 f, err := rp.repoResolver.Resolve(r)
1976 user := rp.oauth.GetUser(r)
1977
1978 // all spindles that the repo owner is a member of
1979 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1980 if err != nil {
1981 log.Println("failed to fetch spindles", err)
1982 return
1983 }
1984
1985 var secrets []*tangled.RepoListSecrets_Secret
1986 if f.Spindle != "" {
1987 if spindleClient, err := rp.oauth.ServiceClient(
1988 r,
1989 oauth.WithService(f.Spindle),
1990 oauth.WithLxm(tangled.RepoListSecretsNSID),
1991 oauth.WithExp(60),
1992 oauth.WithDev(rp.config.Core.Dev),
1993 ); err != nil {
1994 log.Println("failed to create spindle client", err)
1995 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1996 log.Println("failed to fetch secrets", err)
1997 } else {
1998 secrets = resp.Secrets
1999 }
2000 }
2001
2002 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
2003 return strings.Compare(a.Key, b.Key)
2004 })
2005
2006 var dids []string
2007 for _, s := range secrets {
2008 dids = append(dids, s.CreatedBy)
2009 }
2010 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
2011
2012 // convert to a more manageable form
2013 var niceSecret []map[string]any
2014 for id, s := range secrets {
2015 when, _ := time.Parse(time.RFC3339, s.CreatedAt)
2016 niceSecret = append(niceSecret, map[string]any{
2017 "Id": id,
2018 "Key": s.Key,
2019 "CreatedAt": when,
2020 "CreatedBy": resolvedIdents[id].Handle.String(),
2021 })
2022 }
2023
2024 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
2025 LoggedInUser: user,
2026 RepoInfo: f.RepoInfo(user),
2027 Tabs: settingsTabs,
2028 Tab: "pipelines",
2029 Spindles: spindles,
2030 CurrentSpindle: f.Spindle,
2031 Secrets: niceSecret,
2032 })
2033}
2034
2035func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
2036 ref := chi.URLParam(r, "ref")
2037 ref, _ = url.PathUnescape(ref)
2038
2039 user := rp.oauth.GetUser(r)
2040 f, err := rp.repoResolver.Resolve(r)
2041 if err != nil {
2042 log.Printf("failed to resolve source repo: %v", err)
2043 return
2044 }
2045
2046 switch r.Method {
2047 case http.MethodPost:
2048 client, err := rp.oauth.ServiceClient(
2049 r,
2050 oauth.WithService(f.Knot),
2051 oauth.WithLxm(tangled.RepoForkSyncNSID),
2052 oauth.WithDev(rp.config.Core.Dev),
2053 )
2054 if err != nil {
2055 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
2056 return
2057 }
2058
2059 repoInfo := f.RepoInfo(user)
2060 if repoInfo.Source == nil {
2061 rp.pages.Notice(w, "repo", "This repository is not a fork.")
2062 return
2063 }
2064
2065 err = tangled.RepoForkSync(
2066 r.Context(),
2067 client,
2068 &tangled.RepoForkSync_Input{
2069 Did: user.Did,
2070 Name: f.Name,
2071 Source: repoInfo.Source.RepoAt().String(),
2072 Branch: ref,
2073 },
2074 )
2075 if err := xrpcclient.HandleXrpcErr(err); err != nil {
2076 rp.pages.Notice(w, "repo", err.Error())
2077 return
2078 }
2079
2080 rp.pages.HxRefresh(w)
2081 return
2082 }
2083}
2084
2085func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
2086 user := rp.oauth.GetUser(r)
2087 f, err := rp.repoResolver.Resolve(r)
2088 if err != nil {
2089 log.Printf("failed to resolve source repo: %v", err)
2090 return
2091 }
2092
2093 switch r.Method {
2094 case http.MethodGet:
2095 user := rp.oauth.GetUser(r)
2096 knots, err := rp.enforcer.GetKnotsForUser(user.Did)
2097 if err != nil {
2098 rp.pages.Notice(w, "repo", "Invalid user account.")
2099 return
2100 }
2101
2102 rp.pages.ForkRepo(w, pages.ForkRepoParams{
2103 LoggedInUser: user,
2104 Knots: knots,
2105 RepoInfo: f.RepoInfo(user),
2106 })
2107
2108 case http.MethodPost:
2109 l := rp.logger.With("handler", "ForkRepo")
2110
2111 targetKnot := r.FormValue("knot")
2112 if targetKnot == "" {
2113 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
2114 return
2115 }
2116 l = l.With("targetKnot", targetKnot)
2117
2118 ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create")
2119 if err != nil || !ok {
2120 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
2121 return
2122 }
2123
2124 // choose a name for a fork
2125 forkName := r.FormValue("repo_name")
2126 if forkName == "" {
2127 rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
2128 return
2129 }
2130
2131 // this check is *only* to see if the forked repo name already exists
2132 // in the user's account.
2133 existingRepo, err := db.GetRepo(
2134 rp.db,
2135 db.FilterEq("did", user.Did),
2136 db.FilterEq("name", forkName),
2137 )
2138 if err != nil {
2139 if !errors.Is(err, sql.ErrNoRows) {
2140 log.Println("error fetching existing repo from db", "err", err)
2141 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
2142 return
2143 }
2144 } else if existingRepo != nil {
2145 // repo with this name already exists
2146 rp.pages.Notice(w, "repo", "A repository with this name already exists.")
2147 return
2148 }
2149 l = l.With("forkName", forkName)
2150
2151 uri := "https"
2152 if rp.config.Core.Dev {
2153 uri = "http"
2154 }
2155
2156 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
2157 l = l.With("cloneUrl", forkSourceUrl)
2158
2159 sourceAt := f.RepoAt().String()
2160
2161 // create an atproto record for this fork
2162 rkey := tid.TID()
2163 repo := &models.Repo{
2164 Did: user.Did,
2165 Name: forkName,
2166 Knot: targetKnot,
2167 Rkey: rkey,
2168 Source: sourceAt,
2169 Description: f.Repo.Description,
2170 Created: time.Now(),
2171 Labels: models.DefaultLabelDefs(),
2172 }
2173 record := repo.AsRecord()
2174
2175 atpClient, err := rp.oauth.AuthorizedClient(r)
2176 if err != nil {
2177 l.Error("failed to create xrpcclient", "err", err)
2178 rp.pages.Notice(w, "repo", "Failed to fork repository.")
2179 return
2180 }
2181
2182 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
2183 Collection: tangled.RepoNSID,
2184 Repo: user.Did,
2185 Rkey: rkey,
2186 Record: &lexutil.LexiconTypeDecoder{
2187 Val: &record,
2188 },
2189 })
2190 if err != nil {
2191 l.Error("failed to write to PDS", "err", err)
2192 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
2193 return
2194 }
2195
2196 aturi := atresp.Uri
2197 l = l.With("aturi", aturi)
2198 l.Info("wrote to PDS")
2199
2200 tx, err := rp.db.BeginTx(r.Context(), nil)
2201 if err != nil {
2202 l.Info("txn failed", "err", err)
2203 rp.pages.Notice(w, "repo", "Failed to save repository information.")
2204 return
2205 }
2206
2207 // The rollback function reverts a few things on failure:
2208 // - the pending txn
2209 // - the ACLs
2210 // - the atproto record created
2211 rollback := func() {
2212 err1 := tx.Rollback()
2213 err2 := rp.enforcer.E.LoadPolicy()
2214 err3 := rollbackRecord(context.Background(), aturi, atpClient)
2215
2216 // ignore txn complete errors, this is okay
2217 if errors.Is(err1, sql.ErrTxDone) {
2218 err1 = nil
2219 }
2220
2221 if errs := errors.Join(err1, err2, err3); errs != nil {
2222 l.Error("failed to rollback changes", "errs", errs)
2223 return
2224 }
2225 }
2226 defer rollback()
2227
2228 client, err := rp.oauth.ServiceClient(
2229 r,
2230 oauth.WithService(targetKnot),
2231 oauth.WithLxm(tangled.RepoCreateNSID),
2232 oauth.WithDev(rp.config.Core.Dev),
2233 )
2234 if err != nil {
2235 l.Error("could not create service client", "err", err)
2236 rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
2237 return
2238 }
2239
2240 err = tangled.RepoCreate(
2241 r.Context(),
2242 client,
2243 &tangled.RepoCreate_Input{
2244 Rkey: rkey,
2245 Source: &forkSourceUrl,
2246 },
2247 )
2248 if err := xrpcclient.HandleXrpcErr(err); err != nil {
2249 rp.pages.Notice(w, "repo", err.Error())
2250 return
2251 }
2252
2253 err = db.AddRepo(tx, repo)
2254 if err != nil {
2255 log.Println(err)
2256 rp.pages.Notice(w, "repo", "Failed to save repository information.")
2257 return
2258 }
2259
2260 // acls
2261 p, _ := securejoin.SecureJoin(user.Did, forkName)
2262 err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
2263 if err != nil {
2264 log.Println(err)
2265 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
2266 return
2267 }
2268
2269 err = tx.Commit()
2270 if err != nil {
2271 log.Println("failed to commit changes", err)
2272 http.Error(w, err.Error(), http.StatusInternalServerError)
2273 return
2274 }
2275
2276 err = rp.enforcer.E.SavePolicy()
2277 if err != nil {
2278 log.Println("failed to update ACLs", err)
2279 http.Error(w, err.Error(), http.StatusInternalServerError)
2280 return
2281 }
2282
2283 // reset the ATURI because the transaction completed successfully
2284 aturi = ""
2285
2286 rp.notifier.NewRepo(r.Context(), repo)
2287 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName))
2288 }
2289}
2290
2291// this is used to rollback changes made to the PDS
2292//
2293// it is a no-op if the provided ATURI is empty
2294func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
2295 if aturi == "" {
2296 return nil
2297 }
2298
2299 parsed := syntax.ATURI(aturi)
2300
2301 collection := parsed.Collection().String()
2302 repo := parsed.Authority().String()
2303 rkey := parsed.RecordKey().String()
2304
2305 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
2306 Collection: collection,
2307 Repo: repo,
2308 Rkey: rkey,
2309 })
2310 return err
2311}
2312
2313func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
2314 user := rp.oauth.GetUser(r)
2315 f, err := rp.repoResolver.Resolve(r)
2316 if err != nil {
2317 log.Println("failed to get repo and knot", err)
2318 return
2319 }
2320
2321 scheme := "http"
2322 if !rp.config.Core.Dev {
2323 scheme = "https"
2324 }
2325 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2326 xrpcc := &indigoxrpc.Client{
2327 Host: host,
2328 }
2329
2330 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2331 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2332 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2333 log.Println("failed to call XRPC repo.branches", xrpcerr)
2334 rp.pages.Error503(w)
2335 return
2336 }
2337
2338 var branchResult types.RepoBranchesResponse
2339 if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
2340 log.Println("failed to decode XRPC branches response", err)
2341 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2342 return
2343 }
2344 branches := branchResult.Branches
2345
2346 sortBranches(branches)
2347
2348 var defaultBranch string
2349 for _, b := range branches {
2350 if b.IsDefault {
2351 defaultBranch = b.Name
2352 }
2353 }
2354
2355 base := defaultBranch
2356 head := defaultBranch
2357
2358 params := r.URL.Query()
2359 queryBase := params.Get("base")
2360 queryHead := params.Get("head")
2361 if queryBase != "" {
2362 base = queryBase
2363 }
2364 if queryHead != "" {
2365 head = queryHead
2366 }
2367
2368 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2369 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2370 log.Println("failed to call XRPC repo.tags", xrpcerr)
2371 rp.pages.Error503(w)
2372 return
2373 }
2374
2375 var tags types.RepoTagsResponse
2376 if err := json.Unmarshal(tagBytes, &tags); err != nil {
2377 log.Println("failed to decode XRPC tags response", err)
2378 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2379 return
2380 }
2381
2382 repoinfo := f.RepoInfo(user)
2383
2384 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
2385 LoggedInUser: user,
2386 RepoInfo: repoinfo,
2387 Branches: branches,
2388 Tags: tags.Tags,
2389 Base: base,
2390 Head: head,
2391 })
2392}
2393
2394func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
2395 user := rp.oauth.GetUser(r)
2396 f, err := rp.repoResolver.Resolve(r)
2397 if err != nil {
2398 log.Println("failed to get repo and knot", err)
2399 return
2400 }
2401
2402 var diffOpts types.DiffOpts
2403 if d := r.URL.Query().Get("diff"); d == "split" {
2404 diffOpts.Split = true
2405 }
2406
2407 // if user is navigating to one of
2408 // /compare/{base}/{head}
2409 // /compare/{base}...{head}
2410 base := chi.URLParam(r, "base")
2411 head := chi.URLParam(r, "head")
2412 if base == "" && head == "" {
2413 rest := chi.URLParam(r, "*") // master...feature/xyz
2414 parts := strings.SplitN(rest, "...", 2)
2415 if len(parts) == 2 {
2416 base = parts[0]
2417 head = parts[1]
2418 }
2419 }
2420
2421 base, _ = url.PathUnescape(base)
2422 head, _ = url.PathUnescape(head)
2423
2424 if base == "" || head == "" {
2425 log.Printf("invalid comparison")
2426 rp.pages.Error404(w)
2427 return
2428 }
2429
2430 scheme := "http"
2431 if !rp.config.Core.Dev {
2432 scheme = "https"
2433 }
2434 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2435 xrpcc := &indigoxrpc.Client{
2436 Host: host,
2437 }
2438
2439 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2440
2441 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2442 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2443 log.Println("failed to call XRPC repo.branches", xrpcerr)
2444 rp.pages.Error503(w)
2445 return
2446 }
2447
2448 var branches types.RepoBranchesResponse
2449 if err := json.Unmarshal(branchBytes, &branches); err != nil {
2450 log.Println("failed to decode XRPC branches response", err)
2451 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2452 return
2453 }
2454
2455 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2456 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2457 log.Println("failed to call XRPC repo.tags", xrpcerr)
2458 rp.pages.Error503(w)
2459 return
2460 }
2461
2462 var tags types.RepoTagsResponse
2463 if err := json.Unmarshal(tagBytes, &tags); err != nil {
2464 log.Println("failed to decode XRPC tags response", err)
2465 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2466 return
2467 }
2468
2469 compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
2470 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2471 log.Println("failed to call XRPC repo.compare", xrpcerr)
2472 rp.pages.Error503(w)
2473 return
2474 }
2475
2476 var formatPatch types.RepoFormatPatchResponse
2477 if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
2478 log.Println("failed to decode XRPC compare response", err)
2479 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2480 return
2481 }
2482
2483 diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
2484
2485 repoinfo := f.RepoInfo(user)
2486
2487 rp.pages.RepoCompare(w, pages.RepoCompareParams{
2488 LoggedInUser: user,
2489 RepoInfo: repoinfo,
2490 Branches: branches.Branches,
2491 Tags: tags.Tags,
2492 Base: base,
2493 Head: head,
2494 Diff: &diff,
2495 DiffOpts: diffOpts,
2496 })
2497
2498}