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