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