1package state
2
3import (
4 "context"
5 "crypto/hmac"
6 "crypto/sha256"
7 "encoding/hex"
8 "fmt"
9 "log"
10 "log/slog"
11 "net/http"
12 "strings"
13 "time"
14
15 comatproto "github.com/bluesky-social/indigo/api/atproto"
16 "github.com/bluesky-social/indigo/atproto/syntax"
17 lexutil "github.com/bluesky-social/indigo/lex/util"
18 securejoin "github.com/cyphar/filepath-securejoin"
19 "github.com/go-chi/chi/v5"
20 "tangled.sh/tangled.sh/core/api/tangled"
21 "tangled.sh/tangled.sh/core/appview"
22 "tangled.sh/tangled.sh/core/appview/db"
23 "tangled.sh/tangled.sh/core/appview/knotclient"
24 "tangled.sh/tangled.sh/core/appview/oauth"
25 "tangled.sh/tangled.sh/core/appview/pages"
26 "tangled.sh/tangled.sh/core/jetstream"
27 "tangled.sh/tangled.sh/core/rbac"
28)
29
30type State struct {
31 db *db.DB
32 oauth *oauth.OAuth
33 enforcer *rbac.Enforcer
34 tidClock syntax.TIDClock
35 pages *pages.Pages
36 resolver *appview.Resolver
37 jc *jetstream.JetstreamClient
38 config *appview.Config
39}
40
41func Make(config *appview.Config) (*State, error) {
42 d, err := db.Make(config.Core.DbPath)
43 if err != nil {
44 return nil, err
45 }
46
47 enforcer, err := rbac.NewEnforcer(config.Core.DbPath)
48 if err != nil {
49 return nil, err
50 }
51
52 clock := syntax.NewTIDClock(0)
53
54 pgs := pages.NewPages(config)
55
56 resolver := appview.NewResolver()
57
58 oauth := oauth.NewOAuth(d, config)
59
60 wrapper := db.DbWrapper{d}
61 jc, err := jetstream.NewJetstreamClient(
62 config.Jetstream.Endpoint,
63 "appview",
64 []string{
65 tangled.GraphFollowNSID,
66 tangled.FeedStarNSID,
67 tangled.PublicKeyNSID,
68 tangled.RepoArtifactNSID,
69 tangled.ActorProfileNSID,
70 },
71 nil,
72 slog.Default(),
73 wrapper,
74 false,
75 )
76 if err != nil {
77 return nil, fmt.Errorf("failed to create jetstream client: %w", err)
78 }
79 err = jc.StartJetstream(context.Background(), appview.Ingest(wrapper, enforcer))
80 if err != nil {
81 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err)
82 }
83
84 state := &State{
85 d,
86 oauth,
87 enforcer,
88 clock,
89 pgs,
90 resolver,
91 jc,
92 config,
93 }
94
95 return state, nil
96}
97
98func TID(c *syntax.TIDClock) string {
99 return c.Next().String()
100}
101
102// func (s *State) Login(w http.ResponseWriter, r *http.Request) {
103// ctx := r.Context()
104
105// switch r.Method {
106// case http.MethodGet:
107// err := s.pages.Login(w, pages.LoginParams{})
108// if err != nil {
109// log.Printf("rendering login page: %s", err)
110// }
111
112// return
113// case http.MethodPost:
114// handle := strings.TrimPrefix(r.FormValue("handle"), "@")
115// appPassword := r.FormValue("app_password")
116
117// resolved, err := s.resolver.ResolveIdent(ctx, handle)
118// if err != nil {
119// log.Println("failed to resolve handle:", err)
120// s.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle))
121// return
122// }
123
124// atSession, err := s.oauth.CreateInitialSession(ctx, resolved, appPassword)
125// if err != nil {
126// s.pages.Notice(w, "login-msg", "Invalid handle or password.")
127// return
128// }
129// sessionish := auth.CreateSessionWrapper{ServerCreateSession_Output: atSession}
130
131// err = s.oauth.StoreSession(r, w, &sessionish, resolved.PDSEndpoint())
132// if err != nil {
133// s.pages.Notice(w, "login-msg", "Failed to login, try again later.")
134// return
135// }
136
137// log.Printf("successfully saved session for %s (%s)", atSession.Handle, atSession.Did)
138
139// did := resolved.DID.String()
140// defaultKnot := "knot1.tangled.sh"
141
142// go func() {
143// log.Printf("adding %s to default knot", did)
144// err = s.enforcer.AddMember(defaultKnot, did)
145// if err != nil {
146// log.Println("failed to add user to knot1.tangled.sh: ", err)
147// return
148// }
149// err = s.enforcer.E.SavePolicy()
150// if err != nil {
151// log.Println("failed to add user to knot1.tangled.sh: ", err)
152// return
153// }
154
155// secret, err := db.GetRegistrationKey(s.db, defaultKnot)
156// if err != nil {
157// log.Println("failed to get registration key for knot1.tangled.sh")
158// return
159// }
160// signedClient, err := NewSignedClient(defaultKnot, secret, s.config.Core.Dev)
161// resp, err := signedClient.AddMember(did)
162// if err != nil {
163// log.Println("failed to add user to knot1.tangled.sh: ", err)
164// return
165// }
166
167// if resp.StatusCode != http.StatusNoContent {
168// log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode)
169// return
170// }
171// }()
172
173// s.pages.HxRedirect(w, "/")
174// return
175// }
176// }
177
178func (s *State) Logout(w http.ResponseWriter, r *http.Request) {
179 s.oauth.ClearSession(r, w)
180 w.Header().Set("HX-Redirect", "/login")
181 w.WriteHeader(http.StatusSeeOther)
182}
183
184func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
185 user := s.oauth.GetUser(r)
186
187 timeline, err := db.MakeTimeline(s.db)
188 if err != nil {
189 log.Println(err)
190 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
191 }
192
193 var didsToResolve []string
194 for _, ev := range timeline {
195 if ev.Repo != nil {
196 didsToResolve = append(didsToResolve, ev.Repo.Did)
197 if ev.Source != nil {
198 didsToResolve = append(didsToResolve, ev.Source.Did)
199 }
200 }
201 if ev.Follow != nil {
202 didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
203 }
204 if ev.Star != nil {
205 didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did)
206 }
207 }
208
209 resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
210 didHandleMap := make(map[string]string)
211 for _, identity := range resolvedIds {
212 if !identity.Handle.IsInvalidHandle() {
213 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
214 } else {
215 didHandleMap[identity.DID.String()] = identity.DID.String()
216 }
217 }
218
219 s.pages.Timeline(w, pages.TimelineParams{
220 LoggedInUser: user,
221 Timeline: timeline,
222 DidHandleMap: didHandleMap,
223 })
224
225 return
226}
227
228// requires auth
229func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) {
230 switch r.Method {
231 case http.MethodGet:
232 // list open registrations under this did
233
234 return
235 case http.MethodPost:
236 session, err := s.oauth.Store.Get(r, appview.SessionName)
237 if err != nil || session.IsNew {
238 log.Println("unauthorized attempt to generate registration key")
239 http.Error(w, "Forbidden", http.StatusUnauthorized)
240 return
241 }
242
243 did := session.Values[appview.SessionDid].(string)
244
245 // check if domain is valid url, and strip extra bits down to just host
246 domain := r.FormValue("domain")
247 if domain == "" {
248 http.Error(w, "Invalid form", http.StatusBadRequest)
249 return
250 }
251
252 key, err := db.GenerateRegistrationKey(s.db, domain, did)
253
254 if err != nil {
255 log.Println(err)
256 http.Error(w, "unable to register this domain", http.StatusNotAcceptable)
257 return
258 }
259
260 w.Write([]byte(key))
261 }
262}
263
264func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
265 user := chi.URLParam(r, "user")
266 user = strings.TrimPrefix(user, "@")
267
268 if user == "" {
269 w.WriteHeader(http.StatusBadRequest)
270 return
271 }
272
273 id, err := s.resolver.ResolveIdent(r.Context(), user)
274 if err != nil {
275 w.WriteHeader(http.StatusInternalServerError)
276 return
277 }
278
279 pubKeys, err := db.GetPublicKeys(s.db, id.DID.String())
280 if err != nil {
281 w.WriteHeader(http.StatusNotFound)
282 return
283 }
284
285 if len(pubKeys) == 0 {
286 w.WriteHeader(http.StatusNotFound)
287 return
288 }
289
290 for _, k := range pubKeys {
291 key := strings.TrimRight(k.Key, "\n")
292 w.Write([]byte(fmt.Sprintln(key)))
293 }
294}
295
296// create a signed request and check if a node responds to that
297func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) {
298 user := s.oauth.GetUser(r)
299
300 domain := chi.URLParam(r, "domain")
301 if domain == "" {
302 http.Error(w, "malformed url", http.StatusBadRequest)
303 return
304 }
305 log.Println("checking ", domain)
306
307 secret, err := db.GetRegistrationKey(s.db, domain)
308 if err != nil {
309 log.Printf("no key found for domain %s: %s\n", domain, err)
310 return
311 }
312
313 client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
314 if err != nil {
315 log.Println("failed to create client to ", domain)
316 }
317
318 resp, err := client.Init(user.Did)
319 if err != nil {
320 w.Write([]byte("no dice"))
321 log.Println("domain was unreachable after 5 seconds")
322 return
323 }
324
325 if resp.StatusCode == http.StatusConflict {
326 log.Println("status conflict", resp.StatusCode)
327 w.Write([]byte("already registered, sorry!"))
328 return
329 }
330
331 if resp.StatusCode != http.StatusNoContent {
332 log.Println("status nok", resp.StatusCode)
333 w.Write([]byte("no dice"))
334 return
335 }
336
337 // verify response mac
338 signature := resp.Header.Get("X-Signature")
339 signatureBytes, err := hex.DecodeString(signature)
340 if err != nil {
341 return
342 }
343
344 expectedMac := hmac.New(sha256.New, []byte(secret))
345 expectedMac.Write([]byte("ok"))
346
347 if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) {
348 log.Printf("response body signature mismatch: %x\n", signatureBytes)
349 return
350 }
351
352 tx, err := s.db.BeginTx(r.Context(), nil)
353 if err != nil {
354 log.Println("failed to start tx", err)
355 http.Error(w, err.Error(), http.StatusInternalServerError)
356 return
357 }
358 defer func() {
359 tx.Rollback()
360 err = s.enforcer.E.LoadPolicy()
361 if err != nil {
362 log.Println("failed to rollback policies")
363 }
364 }()
365
366 // mark as registered
367 err = db.Register(tx, domain)
368 if err != nil {
369 log.Println("failed to register domain", err)
370 http.Error(w, err.Error(), http.StatusInternalServerError)
371 return
372 }
373
374 // set permissions for this did as owner
375 reg, err := db.RegistrationByDomain(tx, domain)
376 if err != nil {
377 log.Println("failed to register domain", err)
378 http.Error(w, err.Error(), http.StatusInternalServerError)
379 return
380 }
381
382 // add basic acls for this domain
383 err = s.enforcer.AddDomain(domain)
384 if err != nil {
385 log.Println("failed to setup owner of domain", err)
386 http.Error(w, err.Error(), http.StatusInternalServerError)
387 return
388 }
389
390 // add this did as owner of this domain
391 err = s.enforcer.AddOwner(domain, reg.ByDid)
392 if err != nil {
393 log.Println("failed to setup owner of domain", err)
394 http.Error(w, err.Error(), http.StatusInternalServerError)
395 return
396 }
397
398 err = tx.Commit()
399 if err != nil {
400 log.Println("failed to commit changes", err)
401 http.Error(w, err.Error(), http.StatusInternalServerError)
402 return
403 }
404
405 err = s.enforcer.E.SavePolicy()
406 if err != nil {
407 log.Println("failed to update ACLs", err)
408 http.Error(w, err.Error(), http.StatusInternalServerError)
409 return
410 }
411
412 w.Write([]byte("check success"))
413}
414
415func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) {
416 domain := chi.URLParam(r, "domain")
417 if domain == "" {
418 http.Error(w, "malformed url", http.StatusBadRequest)
419 return
420 }
421
422 user := s.oauth.GetUser(r)
423 reg, err := db.RegistrationByDomain(s.db, domain)
424 if err != nil {
425 w.Write([]byte("failed to pull up registration info"))
426 return
427 }
428
429 var members []string
430 if reg.Registered != nil {
431 members, err = s.enforcer.GetUserByRole("server:member", domain)
432 if err != nil {
433 w.Write([]byte("failed to fetch member list"))
434 return
435 }
436 }
437
438 var didsToResolve []string
439 for _, m := range members {
440 didsToResolve = append(didsToResolve, m)
441 }
442 didsToResolve = append(didsToResolve, reg.ByDid)
443 resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
444 didHandleMap := make(map[string]string)
445 for _, identity := range resolvedIds {
446 if !identity.Handle.IsInvalidHandle() {
447 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
448 } else {
449 didHandleMap[identity.DID.String()] = identity.DID.String()
450 }
451 }
452
453 ok, err := s.enforcer.IsServerOwner(user.Did, domain)
454 isOwner := err == nil && ok
455
456 p := pages.KnotParams{
457 LoggedInUser: user,
458 DidHandleMap: didHandleMap,
459 Registration: reg,
460 Members: members,
461 IsOwner: isOwner,
462 }
463
464 s.pages.Knot(w, p)
465}
466
467// get knots registered by this user
468func (s *State) Knots(w http.ResponseWriter, r *http.Request) {
469 // for now, this is just pubkeys
470 user := s.oauth.GetUser(r)
471 registrations, err := db.RegistrationsByDid(s.db, user.Did)
472 if err != nil {
473 log.Println(err)
474 }
475
476 s.pages.Knots(w, pages.KnotsParams{
477 LoggedInUser: user,
478 Registrations: registrations,
479 })
480}
481
482// list members of domain, requires auth and requires owner status
483func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) {
484 domain := chi.URLParam(r, "domain")
485 if domain == "" {
486 http.Error(w, "malformed url", http.StatusBadRequest)
487 return
488 }
489
490 // list all members for this domain
491 memberDids, err := s.enforcer.GetUserByRole("server:member", domain)
492 if err != nil {
493 w.Write([]byte("failed to fetch member list"))
494 return
495 }
496
497 w.Write([]byte(strings.Join(memberDids, "\n")))
498 return
499}
500
501// add member to domain, requires auth and requires invite access
502func (s *State) AddMember(w http.ResponseWriter, r *http.Request) {
503 domain := chi.URLParam(r, "domain")
504 if domain == "" {
505 http.Error(w, "malformed url", http.StatusBadRequest)
506 return
507 }
508
509 subjectIdentifier := r.FormValue("subject")
510 if subjectIdentifier == "" {
511 http.Error(w, "malformed form", http.StatusBadRequest)
512 return
513 }
514
515 subjectIdentity, err := s.resolver.ResolveIdent(r.Context(), subjectIdentifier)
516 if err != nil {
517 w.Write([]byte("failed to resolve member did to a handle"))
518 return
519 }
520 log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain)
521
522 // announce this relation into the firehose, store into owners' pds
523 client, err := s.oauth.AuthorizedClient(r)
524 if err != nil {
525 http.Error(w, "failed to authorize client", http.StatusInternalServerError)
526 return
527 }
528 currentUser := s.oauth.GetUser(r)
529 createdAt := time.Now().Format(time.RFC3339)
530 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
531 Collection: tangled.KnotMemberNSID,
532 Repo: currentUser.Did,
533 Rkey: appview.TID(),
534 Record: &lexutil.LexiconTypeDecoder{
535 Val: &tangled.KnotMember{
536 Subject: subjectIdentity.DID.String(),
537 Domain: domain,
538 CreatedAt: createdAt,
539 }},
540 })
541
542 // invalid record
543 if err != nil {
544 log.Printf("failed to create record: %s", err)
545 return
546 }
547 log.Println("created atproto record: ", resp.Uri)
548
549 secret, err := db.GetRegistrationKey(s.db, domain)
550 if err != nil {
551 log.Printf("no key found for domain %s: %s\n", domain, err)
552 return
553 }
554
555 ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
556 if err != nil {
557 log.Println("failed to create client to ", domain)
558 return
559 }
560
561 ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
562 if err != nil {
563 log.Printf("failed to make request to %s: %s", domain, err)
564 return
565 }
566
567 if ksResp.StatusCode != http.StatusNoContent {
568 w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err)))
569 return
570 }
571
572 err = s.enforcer.AddMember(domain, subjectIdentity.DID.String())
573 if err != nil {
574 w.Write([]byte(fmt.Sprint("failed to add member: ", err)))
575 return
576 }
577
578 w.Write([]byte(fmt.Sprint("added member: ", subjectIdentity.Handle.String())))
579}
580
581func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) {
582}
583
584func validateRepoName(name string) error {
585 // check for path traversal attempts
586 if name == "." || name == ".." ||
587 strings.Contains(name, "/") || strings.Contains(name, "\\") {
588 return fmt.Errorf("Repository name contains invalid path characters")
589 }
590
591 // check for sequences that could be used for traversal when normalized
592 if strings.Contains(name, "./") || strings.Contains(name, "../") ||
593 strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
594 return fmt.Errorf("Repository name contains invalid path sequence")
595 }
596
597 // then continue with character validation
598 for _, char := range name {
599 if !((char >= 'a' && char <= 'z') ||
600 (char >= 'A' && char <= 'Z') ||
601 (char >= '0' && char <= '9') ||
602 char == '-' || char == '_' || char == '.') {
603 return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
604 }
605 }
606
607 // additional check to prevent multiple sequential dots
608 if strings.Contains(name, "..") {
609 return fmt.Errorf("Repository name cannot contain sequential dots")
610 }
611
612 // if all checks pass
613 return nil
614}
615
616func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
617 switch r.Method {
618 case http.MethodGet:
619 user := s.oauth.GetUser(r)
620 knots, err := s.enforcer.GetDomainsForUser(user.Did)
621 if err != nil {
622 s.pages.Notice(w, "repo", "Invalid user account.")
623 return
624 }
625
626 s.pages.NewRepo(w, pages.NewRepoParams{
627 LoggedInUser: user,
628 Knots: knots,
629 })
630
631 case http.MethodPost:
632 user := s.oauth.GetUser(r)
633
634 domain := r.FormValue("domain")
635 if domain == "" {
636 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
637 return
638 }
639
640 repoName := r.FormValue("name")
641 if repoName == "" {
642 s.pages.Notice(w, "repo", "Repository name cannot be empty.")
643 return
644 }
645
646 if err := validateRepoName(repoName); err != nil {
647 s.pages.Notice(w, "repo", err.Error())
648 return
649 }
650
651 defaultBranch := r.FormValue("branch")
652 if defaultBranch == "" {
653 defaultBranch = "main"
654 }
655
656 description := r.FormValue("description")
657
658 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
659 if err != nil || !ok {
660 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
661 return
662 }
663
664 existingRepo, err := db.GetRepo(s.db, user.Did, repoName)
665 if err == nil && existingRepo != nil {
666 s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot))
667 return
668 }
669
670 secret, err := db.GetRegistrationKey(s.db, domain)
671 if err != nil {
672 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
673 return
674 }
675
676 client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
677 if err != nil {
678 s.pages.Notice(w, "repo", "Failed to connect to knot server.")
679 return
680 }
681
682 rkey := appview.TID()
683 repo := &db.Repo{
684 Did: user.Did,
685 Name: repoName,
686 Knot: domain,
687 Rkey: rkey,
688 Description: description,
689 }
690
691 xrpcClient, err := s.oauth.AuthorizedClient(r)
692 if err != nil {
693 s.pages.Notice(w, "repo", "Failed to write record to PDS.")
694 return
695 }
696
697 createdAt := time.Now().Format(time.RFC3339)
698 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
699 Collection: tangled.RepoNSID,
700 Repo: user.Did,
701 Rkey: rkey,
702 Record: &lexutil.LexiconTypeDecoder{
703 Val: &tangled.Repo{
704 Knot: repo.Knot,
705 Name: repoName,
706 CreatedAt: createdAt,
707 Owner: user.Did,
708 }},
709 })
710 if err != nil {
711 log.Printf("failed to create record: %s", err)
712 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
713 return
714 }
715 log.Println("created repo record: ", atresp.Uri)
716
717 tx, err := s.db.BeginTx(r.Context(), nil)
718 if err != nil {
719 log.Println(err)
720 s.pages.Notice(w, "repo", "Failed to save repository information.")
721 return
722 }
723 defer func() {
724 tx.Rollback()
725 err = s.enforcer.E.LoadPolicy()
726 if err != nil {
727 log.Println("failed to rollback policies")
728 }
729 }()
730
731 resp, err := client.NewRepo(user.Did, repoName, defaultBranch)
732 if err != nil {
733 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
734 return
735 }
736
737 switch resp.StatusCode {
738 case http.StatusConflict:
739 s.pages.Notice(w, "repo", "A repository with that name already exists.")
740 return
741 case http.StatusInternalServerError:
742 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
743 case http.StatusNoContent:
744 // continue
745 }
746
747 repo.AtUri = atresp.Uri
748 err = db.AddRepo(tx, repo)
749 if err != nil {
750 log.Println(err)
751 s.pages.Notice(w, "repo", "Failed to save repository information.")
752 return
753 }
754
755 // acls
756 p, _ := securejoin.SecureJoin(user.Did, repoName)
757 err = s.enforcer.AddRepo(user.Did, domain, p)
758 if err != nil {
759 log.Println(err)
760 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
761 return
762 }
763
764 err = tx.Commit()
765 if err != nil {
766 log.Println("failed to commit changes", err)
767 http.Error(w, err.Error(), http.StatusInternalServerError)
768 return
769 }
770
771 err = s.enforcer.E.SavePolicy()
772 if err != nil {
773 log.Println("failed to update ACLs", err)
774 http.Error(w, err.Error(), http.StatusInternalServerError)
775 return
776 }
777
778 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
779 return
780 }
781}