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