forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

+35 -20
appview/db/issues.go
··· 5 "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 ) 9 10 type Issue struct { ··· 102 return ownerDid, err 103 } 104 105 - func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool) ([]Issue, error) { 106 var issues []Issue 107 openValue := 0 108 if isOpen { ··· 110 } 111 112 rows, err := e.Query( 113 - `select 114 - i.owner_did, 115 - i.issue_id, 116 - i.created, 117 - i.title, 118 - i.body, 119 - i.open, 120 - count(c.id) 121 - from 122 - issues i 123 - left join 124 - comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 125 - where 126 - i.repo_at = ? and i.open = ? 127 - group by 128 - i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 129 - order by 130 - i.created desc`, 131 - repoAt, openValue) 132 if err != nil { 133 return nil, err 134 }
··· 5 "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.sh/tangled.sh/core/appview/pagination" 9 ) 10 11 type Issue struct { ··· 103 return ownerDid, err 104 } 105 106 + func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 107 var issues []Issue 108 openValue := 0 109 if isOpen { ··· 111 } 112 113 rows, err := e.Query( 114 + ` 115 + with numbered_issue as ( 116 + select 117 + i.owner_did, 118 + i.issue_id, 119 + i.created, 120 + i.title, 121 + i.body, 122 + i.open, 123 + count(c.id) as comment_count, 124 + row_number() over (order by i.created desc) as row_num 125 + from 126 + issues i 127 + left join 128 + comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 129 + where 130 + i.repo_at = ? and i.open = ? 131 + group by 132 + i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 133 + ) 134 + select 135 + owner_did, 136 + issue_id, 137 + created, 138 + title, 139 + body, 140 + open, 141 + comment_count 142 + from 143 + numbered_issue 144 + where 145 + row_num between ? and ?`, 146 + repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 147 if err != nil { 148 return nil, err 149 }
+12
appview/db/pulls.go
··· 654 return nil, err 655 } 656 657 pull.Submissions = make([]*PullSubmission, len(submissionsMap)) 658 for _, submission := range submissionsMap { 659 pull.Submissions[submission.RoundNumber] = submission
··· 654 return nil, err 655 } 656 657 + var pullSourceRepo *Repo 658 + if pull.PullSource != nil { 659 + if pull.PullSource.RepoAt != nil { 660 + pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 661 + if err != nil { 662 + log.Printf("failed to get repo by at uri: %v", err) 663 + } else { 664 + pull.PullSource.Repo = pullSourceRepo 665 + } 666 + } 667 + } 668 + 669 pull.Submissions = make([]*PullSubmission, len(submissionsMap)) 670 for _, submission := range submissionsMap { 671 pull.Submissions[submission.RoundNumber] = submission
+62
appview/filetree/filetree.go
···
··· 1 + package filetree 2 + 3 + import ( 4 + "path/filepath" 5 + "sort" 6 + "strings" 7 + ) 8 + 9 + type FileTreeNode struct { 10 + Name string 11 + Path string 12 + IsDirectory bool 13 + Children map[string]*FileTreeNode 14 + } 15 + 16 + // NewNode creates a new node 17 + func newNode(name, path string, isDir bool) *FileTreeNode { 18 + return &FileTreeNode{ 19 + Name: name, 20 + Path: path, 21 + IsDirectory: isDir, 22 + Children: make(map[string]*FileTreeNode), 23 + } 24 + } 25 + 26 + func FileTree(files []string) *FileTreeNode { 27 + rootNode := newNode("", "", true) 28 + 29 + sort.Strings(files) 30 + 31 + for _, file := range files { 32 + if file == "" { 33 + continue 34 + } 35 + 36 + parts := strings.Split(filepath.Clean(file), "/") 37 + if len(parts) == 0 { 38 + continue 39 + } 40 + 41 + currentNode := rootNode 42 + currentPath := "" 43 + 44 + for i, part := range parts { 45 + if currentPath == "" { 46 + currentPath = part 47 + } else { 48 + currentPath = filepath.Join(currentPath, part) 49 + } 50 + 51 + isDir := i < len(parts)-1 52 + 53 + if _, exists := currentNode.Children[part]; !exists { 54 + currentNode.Children[part] = newNode(part, currentPath, isDir) 55 + } 56 + 57 + currentNode = currentNode.Children[part] 58 + } 59 + } 60 + 61 + return rootNode 62 + }
+126
appview/middleware/middleware.go
···
··· 1 + package middleware 2 + 3 + import ( 4 + "context" 5 + "log" 6 + "net/http" 7 + "strconv" 8 + "time" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + "tangled.sh/tangled.sh/core/appview" 13 + "tangled.sh/tangled.sh/core/appview/auth" 14 + "tangled.sh/tangled.sh/core/appview/pagination" 15 + ) 16 + 17 + type Middleware func(http.Handler) http.Handler 18 + 19 + func AuthMiddleware(a *auth.Auth) Middleware { 20 + return func(next http.Handler) http.Handler { 21 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 + redirectFunc := func(w http.ResponseWriter, r *http.Request) { 23 + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 24 + } 25 + if r.Header.Get("HX-Request") == "true" { 26 + redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 27 + w.Header().Set("HX-Redirect", "/login") 28 + w.WriteHeader(http.StatusOK) 29 + } 30 + } 31 + 32 + session, err := a.GetSession(r) 33 + if session.IsNew || err != nil { 34 + log.Printf("not logged in, redirecting") 35 + redirectFunc(w, r) 36 + return 37 + } 38 + 39 + authorized, ok := session.Values[appview.SessionAuthenticated].(bool) 40 + if !ok || !authorized { 41 + log.Printf("not logged in, redirecting") 42 + redirectFunc(w, r) 43 + return 44 + } 45 + 46 + // refresh if nearing expiry 47 + // TODO: dedup with /login 48 + expiryStr := session.Values[appview.SessionExpiry].(string) 49 + expiry, err := time.Parse(time.RFC3339, expiryStr) 50 + if err != nil { 51 + log.Println("invalid expiry time", err) 52 + redirectFunc(w, r) 53 + return 54 + } 55 + pdsUrl, ok1 := session.Values[appview.SessionPds].(string) 56 + did, ok2 := session.Values[appview.SessionDid].(string) 57 + refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string) 58 + 59 + if !ok1 || !ok2 || !ok3 { 60 + log.Println("invalid expiry time", err) 61 + redirectFunc(w, r) 62 + return 63 + } 64 + 65 + if time.Now().After(expiry) { 66 + log.Println("token expired, refreshing ...") 67 + 68 + client := xrpc.Client{ 69 + Host: pdsUrl, 70 + Auth: &xrpc.AuthInfo{ 71 + Did: did, 72 + AccessJwt: refreshJwt, 73 + RefreshJwt: refreshJwt, 74 + }, 75 + } 76 + atSession, err := comatproto.ServerRefreshSession(r.Context(), &client) 77 + if err != nil { 78 + log.Println("failed to refresh session", err) 79 + redirectFunc(w, r) 80 + return 81 + } 82 + 83 + sessionish := auth.RefreshSessionWrapper{atSession} 84 + 85 + err = a.StoreSession(r, w, &sessionish, pdsUrl) 86 + if err != nil { 87 + log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err) 88 + return 89 + } 90 + 91 + log.Println("successfully refreshed token") 92 + } 93 + 94 + next.ServeHTTP(w, r) 95 + }) 96 + } 97 + } 98 + 99 + func Paginate(next http.Handler) http.Handler { 100 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 101 + page := pagination.FirstPage() 102 + 103 + offsetVal := r.URL.Query().Get("offset") 104 + if offsetVal != "" { 105 + offset, err := strconv.Atoi(offsetVal) 106 + if err != nil { 107 + log.Println("invalid offset") 108 + } else { 109 + page.Offset = offset 110 + } 111 + } 112 + 113 + limitVal := r.URL.Query().Get("limit") 114 + if limitVal != "" { 115 + limit, err := strconv.Atoi(limitVal) 116 + if err != nil { 117 + log.Println("invalid limit") 118 + } else { 119 + page.Limit = limit 120 + } 121 + } 122 + 123 + ctx := context.WithValue(r.Context(), "page", page) 124 + next.ServeHTTP(w, r.WithContext(ctx)) 125 + }) 126 + }
+2
appview/pages/funcmap.go
··· 13 "time" 14 15 "github.com/dustin/go-humanize" 16 "tangled.sh/tangled.sh/core/appview/pages/markup" 17 ) 18 ··· 174 return template.HTML(data) 175 }, 176 "cssContentHash": CssContentHash, 177 } 178 } 179
··· 13 "time" 14 15 "github.com/dustin/go-humanize" 16 + "tangled.sh/tangled.sh/core/appview/filetree" 17 "tangled.sh/tangled.sh/core/appview/pages/markup" 18 ) 19 ··· 175 return template.HTML(data) 176 }, 177 "cssContentHash": CssContentHash, 178 + "fileTree": filetree.FileTree, 179 } 180 } 181
+136 -45
appview/pages/pages.go
··· 11 "io/fs" 12 "log" 13 "net/http" 14 "path" 15 "path/filepath" 16 "slices" ··· 19 "tangled.sh/tangled.sh/core/appview/auth" 20 "tangled.sh/tangled.sh/core/appview/db" 21 "tangled.sh/tangled.sh/core/appview/pages/markup" 22 "tangled.sh/tangled.sh/core/appview/state/userutil" 23 "tangled.sh/tangled.sh/core/patchutil" 24 "tangled.sh/tangled.sh/core/types" ··· 35 var Files embed.FS 36 37 type Pages struct { 38 - t map[string]*template.Template 39 } 40 41 - func NewPages() *Pages { 42 - templates := make(map[string]*template.Template) 43 44 var fragmentPaths []string 45 // First, collect all fragment paths 46 - err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { 47 if err != nil { 48 return err 49 } 50 - 51 if d.IsDir() { 52 return nil 53 } 54 - 55 if !strings.HasSuffix(path, ".html") { 56 return nil 57 } 58 - 59 if !strings.Contains(path, "fragments/") { 60 return nil 61 } 62 - 63 name := strings.TrimPrefix(path, "templates/") 64 name = strings.TrimSuffix(name, ".html") 65 - 66 tmpl, err := template.New(name). 67 Funcs(funcMap()). 68 - ParseFS(Files, path) 69 if err != nil { 70 log.Fatalf("setting up fragment: %v", err) 71 } 72 - 73 templates[name] = tmpl 74 fragmentPaths = append(fragmentPaths, path) 75 log.Printf("loaded fragment: %s", name) ··· 80 } 81 82 // Then walk through and setup the rest of the templates 83 - err = fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { 84 if err != nil { 85 return err 86 } 87 - 88 if d.IsDir() { 89 return nil 90 } 91 - 92 if !strings.HasSuffix(path, "html") { 93 return nil 94 } 95 - 96 // Skip fragments as they've already been loaded 97 if strings.Contains(path, "fragments/") { 98 return nil 99 } 100 - 101 // Skip layouts 102 if strings.Contains(path, "layouts/") { 103 return nil 104 } 105 - 106 name := strings.TrimPrefix(path, "templates/") 107 name = strings.TrimSuffix(name, ".html") 108 - 109 // Add the page template on top of the base 110 allPaths := []string{} 111 allPaths = append(allPaths, "templates/layouts/*.html") ··· 113 allPaths = append(allPaths, path) 114 tmpl, err := template.New(name). 115 Funcs(funcMap()). 116 - ParseFS(Files, allPaths...) 117 if err != nil { 118 return fmt.Errorf("setting up template: %w", err) 119 } 120 - 121 templates[name] = tmpl 122 log.Printf("loaded template: %s", name) 123 return nil ··· 127 } 128 129 log.Printf("total templates loaded: %d", len(templates)) 130 131 - return &Pages{ 132 - t: templates, 133 } 134 } 135 136 - type LoginParams struct { 137 } 138 139 func (p *Pages) execute(name string, w io.Writer, params any) error { 140 - return p.t[name].ExecuteTemplate(w, "layouts/base", params) 141 } 142 143 func (p *Pages) executePlain(name string, w io.Writer, params any) error { 144 - return p.t[name].Execute(w, params) 145 } 146 147 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 148 - return p.t[name].ExecuteTemplate(w, "layouts/repobase", params) 149 } 150 151 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 419 } 420 421 type RepoCommitParams struct { 422 - LoggedInUser *auth.User 423 - RepoInfo RepoInfo 424 - Active string 425 types.RepoCommitResponse 426 - EmailToDidOrHandle map[string]string 427 } 428 429 func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { ··· 564 } 565 566 type RepoIssuesParams struct { 567 - LoggedInUser *auth.User 568 - RepoInfo RepoInfo 569 - Active string 570 - Issues []db.Issue 571 - DidHandleMap map[string]string 572 - 573 FilteringByOpen bool 574 } 575 ··· 679 } 680 681 type RepoSinglePullParams struct { 682 - LoggedInUser *auth.User 683 - RepoInfo RepoInfo 684 - Active string 685 - DidHandleMap map[string]string 686 - Pull *db.Pull 687 - PullSourceRepo *db.Repo 688 - MergeCheck types.MergeCheckResponse 689 - ResubmitCheck ResubmitResult 690 } 691 692 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 699 DidHandleMap map[string]string 700 RepoInfo RepoInfo 701 Pull *db.Pull 702 - Diff types.NiceDiff 703 Round int 704 Submission *db.PullSubmission 705 } ··· 795 } 796 797 func (p *Pages) Static() http.Handler { 798 sub, err := fs.Sub(Files, "static") 799 if err != nil { 800 log.Fatalf("no static dir found? that's crazy: %v", err)
··· 11 "io/fs" 12 "log" 13 "net/http" 14 + "os" 15 "path" 16 "path/filepath" 17 "slices" ··· 20 "tangled.sh/tangled.sh/core/appview/auth" 21 "tangled.sh/tangled.sh/core/appview/db" 22 "tangled.sh/tangled.sh/core/appview/pages/markup" 23 + "tangled.sh/tangled.sh/core/appview/pagination" 24 "tangled.sh/tangled.sh/core/appview/state/userutil" 25 "tangled.sh/tangled.sh/core/patchutil" 26 "tangled.sh/tangled.sh/core/types" ··· 37 var Files embed.FS 38 39 type Pages struct { 40 + t map[string]*template.Template 41 + dev bool 42 + embedFS embed.FS 43 + templateDir string // Path to templates on disk for dev mode 44 } 45 46 + func NewPages(dev bool) *Pages { 47 + p := &Pages{ 48 + t: make(map[string]*template.Template), 49 + dev: dev, 50 + embedFS: Files, 51 + templateDir: "appview/pages", 52 + } 53 + 54 + // Initial load of all templates 55 + p.loadAllTemplates() 56 + 57 + return p 58 + } 59 60 + func (p *Pages) loadAllTemplates() { 61 + templates := make(map[string]*template.Template) 62 var fragmentPaths []string 63 + 64 + // Use embedded FS for initial loading 65 // First, collect all fragment paths 66 + err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 67 if err != nil { 68 return err 69 } 70 if d.IsDir() { 71 return nil 72 } 73 if !strings.HasSuffix(path, ".html") { 74 return nil 75 } 76 if !strings.Contains(path, "fragments/") { 77 return nil 78 } 79 name := strings.TrimPrefix(path, "templates/") 80 name = strings.TrimSuffix(name, ".html") 81 tmpl, err := template.New(name). 82 Funcs(funcMap()). 83 + ParseFS(p.embedFS, path) 84 if err != nil { 85 log.Fatalf("setting up fragment: %v", err) 86 } 87 templates[name] = tmpl 88 fragmentPaths = append(fragmentPaths, path) 89 log.Printf("loaded fragment: %s", name) ··· 94 } 95 96 // Then walk through and setup the rest of the templates 97 + err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 98 if err != nil { 99 return err 100 } 101 if d.IsDir() { 102 return nil 103 } 104 if !strings.HasSuffix(path, "html") { 105 return nil 106 } 107 // Skip fragments as they've already been loaded 108 if strings.Contains(path, "fragments/") { 109 return nil 110 } 111 // Skip layouts 112 if strings.Contains(path, "layouts/") { 113 return nil 114 } 115 name := strings.TrimPrefix(path, "templates/") 116 name = strings.TrimSuffix(name, ".html") 117 // Add the page template on top of the base 118 allPaths := []string{} 119 allPaths = append(allPaths, "templates/layouts/*.html") ··· 121 allPaths = append(allPaths, path) 122 tmpl, err := template.New(name). 123 Funcs(funcMap()). 124 + ParseFS(p.embedFS, allPaths...) 125 if err != nil { 126 return fmt.Errorf("setting up template: %w", err) 127 } 128 templates[name] = tmpl 129 log.Printf("loaded template: %s", name) 130 return nil ··· 134 } 135 136 log.Printf("total templates loaded: %d", len(templates)) 137 + p.t = templates 138 + } 139 140 + // loadTemplateFromDisk loads a template from the filesystem in dev mode 141 + func (p *Pages) loadTemplateFromDisk(name string) error { 142 + if !p.dev { 143 + return nil 144 + } 145 + 146 + log.Printf("reloading template from disk: %s", name) 147 + 148 + // Find all fragments first 149 + var fragmentPaths []string 150 + err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error { 151 + if err != nil { 152 + return err 153 + } 154 + if d.IsDir() { 155 + return nil 156 + } 157 + if !strings.HasSuffix(path, ".html") { 158 + return nil 159 + } 160 + if !strings.Contains(path, "fragments/") { 161 + return nil 162 + } 163 + fragmentPaths = append(fragmentPaths, path) 164 + return nil 165 + }) 166 + if err != nil { 167 + return fmt.Errorf("walking disk template dir for fragments: %w", err) 168 + } 169 + 170 + // Find the template path on disk 171 + templatePath := filepath.Join(p.templateDir, "templates", name+".html") 172 + if _, err := os.Stat(templatePath); os.IsNotExist(err) { 173 + return fmt.Errorf("template not found on disk: %s", name) 174 + } 175 + 176 + // Create a new template 177 + tmpl := template.New(name).Funcs(funcMap()) 178 + 179 + // Parse layouts 180 + layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 181 + layouts, err := filepath.Glob(layoutGlob) 182 + if err != nil { 183 + return fmt.Errorf("finding layout templates: %w", err) 184 + } 185 + 186 + // Create paths for parsing 187 + allFiles := append(layouts, fragmentPaths...) 188 + allFiles = append(allFiles, templatePath) 189 + 190 + // Parse all templates 191 + tmpl, err = tmpl.ParseFiles(allFiles...) 192 + if err != nil { 193 + return fmt.Errorf("parsing template files: %w", err) 194 } 195 + 196 + // Update the template in the map 197 + p.t[name] = tmpl 198 + log.Printf("template reloaded from disk: %s", name) 199 + return nil 200 } 201 202 + func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 203 + // In dev mode, reload the template from disk before executing 204 + if p.dev { 205 + if err := p.loadTemplateFromDisk(templateName); err != nil { 206 + log.Printf("warning: failed to reload template %s from disk: %v", templateName, err) 207 + // Continue with the existing template 208 + } 209 + } 210 + 211 + tmpl, exists := p.t[templateName] 212 + if !exists { 213 + return fmt.Errorf("template not found: %s", templateName) 214 + } 215 + 216 + if base == "" { 217 + return tmpl.Execute(w, params) 218 + } else { 219 + return tmpl.ExecuteTemplate(w, base, params) 220 + } 221 } 222 223 func (p *Pages) execute(name string, w io.Writer, params any) error { 224 + return p.executeOrReload(name, w, "layouts/base", params) 225 } 226 227 func (p *Pages) executePlain(name string, w io.Writer, params any) error { 228 + return p.executeOrReload(name, w, "", params) 229 } 230 231 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 232 + return p.executeOrReload(name, w, "layouts/repobase", params) 233 + } 234 + 235 + type LoginParams struct { 236 } 237 238 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 506 } 507 508 type RepoCommitParams struct { 509 + LoggedInUser *auth.User 510 + RepoInfo RepoInfo 511 + Active string 512 + EmailToDidOrHandle map[string]string 513 + 514 types.RepoCommitResponse 515 } 516 517 func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { ··· 652 } 653 654 type RepoIssuesParams struct { 655 + LoggedInUser *auth.User 656 + RepoInfo RepoInfo 657 + Active string 658 + Issues []db.Issue 659 + DidHandleMap map[string]string 660 + Page pagination.Page 661 FilteringByOpen bool 662 } 663 ··· 767 } 768 769 type RepoSinglePullParams struct { 770 + LoggedInUser *auth.User 771 + RepoInfo RepoInfo 772 + Active string 773 + DidHandleMap map[string]string 774 + Pull *db.Pull 775 + MergeCheck types.MergeCheckResponse 776 + ResubmitCheck ResubmitResult 777 } 778 779 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 786 DidHandleMap map[string]string 787 RepoInfo RepoInfo 788 Pull *db.Pull 789 + Diff *types.NiceDiff 790 Round int 791 Submission *db.PullSubmission 792 } ··· 882 } 883 884 func (p *Pages) Static() http.Handler { 885 + if p.dev { 886 + return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 887 + } 888 + 889 sub, err := fs.Sub(Files, "static") 890 if err != nil { 891 log.Fatalf("no static dir found? that's crazy: %v", err)
+4 -17
appview/pages/templates/repo/fragments/diff.html
··· 3 {{ $diff := index . 1 }} 4 {{ $commit := $diff.Commit }} 5 {{ $stat := $diff.Stat }} 6 {{ $diff := $diff.Diff }} 7 8 {{ $this := $commit.This }} ··· 14 <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong> 15 {{ block "statPill" $stat }} {{ end }} 16 </div> 17 - <div class="overflow-x-auto"> 18 - {{ range $diff }} 19 - <ul class="dark:text-gray-200"> 20 - {{ if .IsDelete }} 21 - <li><a href="#file-{{ .Name.Old }}" class="dark:hover:text-gray-300">{{ .Name.Old }}</a></li> 22 - {{ else }} 23 - <li><a href="#file-{{ .Name.New }}" class="dark:hover:text-gray-300">{{ .Name.New }}</a></li> 24 - {{ end }} 25 - </ul> 26 - {{ end }} 27 - </div> 28 </div> 29 </section> 30 ··· 38 <summary class="list-none cursor-pointer sticky top-0"> 39 <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 40 <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 41 - <div class="flex gap-1 items-center" style="direction: ltr;"> 42 {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 43 {{ if .IsNew }} 44 <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span> ··· 55 {{ block "statPill" .Stats }} {{ end }} 56 </div> 57 58 - <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;"> 59 {{ if .IsDelete }} 60 <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}> 61 {{ .Name.Old }} ··· 101 {{ else if .IsCopy }} 102 <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 103 This file has been copied. 104 - </p> 105 - {{ else if .IsRename }} 106 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 107 - This file has been renamed. 108 </p> 109 {{ else if .IsBinary }} 110 <p class="text-center text-gray-400 dark:text-gray-500 p-4">
··· 3 {{ $diff := index . 1 }} 4 {{ $commit := $diff.Commit }} 5 {{ $stat := $diff.Stat }} 6 + {{ $fileTree := fileTree $diff.ChangedFiles }} 7 {{ $diff := $diff.Diff }} 8 9 {{ $this := $commit.This }} ··· 15 <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong> 16 {{ block "statPill" $stat }} {{ end }} 17 </div> 18 + {{ block "fileTree" $fileTree }} {{ end }} 19 </div> 20 </section> 21 ··· 29 <summary class="list-none cursor-pointer sticky top-0"> 30 <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 31 <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 32 + <div class="flex gap-1 items-center"> 33 {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 34 {{ if .IsNew }} 35 <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span> ··· 46 {{ block "statPill" .Stats }} {{ end }} 47 </div> 48 49 + <div class="flex gap-2 items-center overflow-x-auto"> 50 {{ if .IsDelete }} 51 <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}> 52 {{ .Name.Old }} ··· 92 {{ else if .IsCopy }} 93 <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 94 This file has been copied. 95 </p> 96 {{ else if .IsBinary }} 97 <p class="text-center text-gray-400 dark:text-gray-500 p-4">
+27
appview/pages/templates/repo/fragments/filetree.html
···
··· 1 + {{ define "fileTree" }} 2 + {{ if and .Name .IsDirectory }} 3 + <details open> 4 + <summary class="cursor-pointer list-none pt-1"> 5 + <span class="inline-flex items-center gap-2 "> 6 + {{ i "folder" "w-3 h-3 fill-current" }} 7 + <span class="text-black dark:text-white">{{ .Name }}</span> 8 + </span> 9 + </summary> 10 + <div class="ml-1 pl-4 border-l border-gray-200 dark:border-gray-700"> 11 + {{ range $child := .Children }} 12 + {{ block "fileTree" $child }} {{ end }} 13 + {{ end }} 14 + </div> 15 + </details> 16 + {{ else if .Name }} 17 + <div class="flex items-center gap-2 pt-1"> 18 + {{ i "file" "w-3 h-3" }} 19 + <a href="#file-{{ .Path }}" class="text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 20 + </div> 21 + {{ else }} 22 + {{ range $child := .Children }} 23 + {{ block "fileTree" $child }} {{ end }} 24 + {{ end }} 25 + {{ end }} 26 + {{ end }} 27 +
+2 -7
appview/pages/templates/repo/fragments/interdiff.html
··· 1 {{ define "repo/fragments/interdiff" }} 2 {{ $repo := index . 0 }} 3 {{ $x := index . 1 }} 4 {{ $diff := $x.Files }} 5 6 <section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> ··· 8 <div class="flex gap-2 items-center"> 9 <strong class="text-sm uppercase dark:text-gray-200">files</strong> 10 </div> 11 - <div class="overflow-x-auto"> 12 - <ul class="dark:text-gray-200"> 13 - {{ range $diff }} 14 - <li><a href="#file-{{ .Name }}" class="dark:hover:text-gray-300">{{ .Name }}</a></li> 15 - {{ end }} 16 - </ul> 17 - </div> 18 </div> 19 </section> 20
··· 1 {{ define "repo/fragments/interdiff" }} 2 {{ $repo := index . 0 }} 3 {{ $x := index . 1 }} 4 + {{ $fileTree := fileTree $x.AffectedFiles }} 5 {{ $diff := $x.Files }} 6 7 <section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> ··· 9 <div class="flex gap-2 items-center"> 10 <strong class="text-sm uppercase dark:text-gray-200">files</strong> 11 </div> 12 + {{ block "fileTree" $fileTree }} {{ end }} 13 </div> 14 </section> 15
+38
appview/pages/templates/repo/issues/issues.html
··· 70 </div> 71 {{ end }} 72 </div> 73 {{ end }}
··· 70 </div> 71 {{ end }} 72 </div> 73 + 74 + {{ block "pagination" . }} {{ end }} 75 + 76 + {{ end }} 77 + 78 + {{ define "pagination" }} 79 + <div class="flex justify-end mt-4 gap-2"> 80 + {{ $currentState := "closed" }} 81 + {{ if .FilteringByOpen }} 82 + {{ $currentState = "open" }} 83 + {{ end }} 84 + 85 + {{ if gt .Page.Offset 0 }} 86 + {{ $prev := .Page.Previous }} 87 + <a 88 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 89 + hx-boost="true" 90 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 91 + > 92 + {{ i "chevron-left" "w-4 h-4" }} 93 + previous 94 + </a> 95 + {{ else }} 96 + <div></div> 97 + {{ end }} 98 + 99 + {{ if eq (len .Issues) .Page.Limit }} 100 + {{ $next := .Page.Next }} 101 + <a 102 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 103 + hx-boost="true" 104 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 105 + > 106 + next 107 + {{ i "chevron-right" "w-4 h-4" }} 108 + </a> 109 + {{ end }} 110 + </div> 111 {{ end }}
+8 -8
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 43 </span> 44 {{ if not .Pull.IsPatchBased }} 45 <span>from 46 - {{ if not .Pull.IsBranchBased }} 47 - <a href="/{{ $owner }}/{{ .PullSourceRepo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSourceRepo.Name }}</a> 48 {{ end }} 49 50 - {{ $fullRepo := .RepoInfo.FullName }} 51 - {{ if not .Pull.IsBranchBased }} 52 - {{ $fullRepo = printf "%s/%s" $owner .PullSourceRepo.Name }} 53 - {{ end }} 54 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 55 - <a href="/{{ $fullRepo }}/tree/{{ .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 56 </span> 57 </span> 58 {{ end }} ··· 67 </section> 68 69 70 - {{ end }}
··· 43 </span> 44 {{ if not .Pull.IsPatchBased }} 45 <span>from 46 + {{ if .Pull.IsForkBased }} 47 + {{ if .Pull.PullSource.Repo }} 48 + <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a> 49 + {{ else }} 50 + <span class="italic">[deleted fork]</span> 51 + {{ end }} 52 {{ end }} 53 54 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 55 + {{ .Pull.PullSource.Branch }} 56 </span> 57 </span> 58 {{ end }} ··· 67 </section> 68 69 70 + {{ end }}
+7 -3
appview/pages/templates/repo/pulls/pull.html
··· 90 <div class="text-sm text-gray-500 dark:text-gray-400"> 91 {{ if not $.Pull.IsPatchBased }} 92 {{ $fullRepo := $.RepoInfo.FullName }} 93 - {{ if not $.Pull.IsBranchBased }} 94 - {{ $fullRepo = printf "%s/%s" $owner $.PullSourceRepo.Name }} 95 {{ end }} 96 - <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-500 dark:text-gray-400">{{ slice .SHA 0 8 }}</a> 97 {{ else }} 98 <span class="font-mono">{{ slice .SHA 0 8 }}</span> 99 {{ end }}
··· 90 <div class="text-sm text-gray-500 dark:text-gray-400"> 91 {{ if not $.Pull.IsPatchBased }} 92 {{ $fullRepo := $.RepoInfo.FullName }} 93 + {{ if $.Pull.IsForkBased }} 94 + {{ if $.Pull.PullSource.Repo }} 95 + {{ $fullRepo = printf "%s/%s" $owner $.Pull.PullSource.Repo.Name }} 96 + <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-500 dark:text-gray-400">{{ slice .SHA 0 8 }}</a> 97 + {{ else }} 98 + <span class="font-mono">{{ slice .SHA 0 8 }}</span> 99 + {{ end }} 100 {{ end }} 101 {{ else }} 102 <span class="font-mono">{{ slice .SHA 0 8 }}</span> 103 {{ end }}
+1 -1
appview/pages/templates/user/profile.html
··· 179 180 181 {{ if gt $stats.Closed 0 }} 182 - <span class="px-2 py-1/2 text-sm rounded text-black dark:text-white bg-gray-50 dark:bg-gray-700 "> 183 {{$stats.Closed}} closed 184 </span> 185 {{ end }}
··· 179 180 181 {{ if gt $stats.Closed 0 }} 182 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 183 {{$stats.Closed}} closed 184 </span> 185 {{ end }}
+31
appview/pagination/page.go
···
··· 1 + package pagination 2 + 3 + type Page struct { 4 + Offset int // where to start from 5 + Limit int // number of items in a page 6 + } 7 + 8 + func FirstPage() Page { 9 + return Page{ 10 + Offset: 0, 11 + Limit: 10, 12 + } 13 + } 14 + 15 + func (p Page) Previous() Page { 16 + if p.Offset-p.Limit < 0 { 17 + return FirstPage() 18 + } else { 19 + return Page{ 20 + Offset: p.Offset - p.Limit, 21 + Limit: p.Limit, 22 + } 23 + } 24 + } 25 + 26 + func (p Page) Next() Page { 27 + return Page{ 28 + Offset: p.Offset + p.Limit, 29 + Limit: p.Limit, 30 + } 31 + }
+451
appview/settings/settings.go
···
··· 1 + package settings 2 + 3 + import ( 4 + "database/sql" 5 + "errors" 6 + "fmt" 7 + "log" 8 + "net/http" 9 + "net/url" 10 + "strings" 11 + "time" 12 + 13 + "github.com/go-chi/chi/v5" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + "tangled.sh/tangled.sh/core/appview" 16 + "tangled.sh/tangled.sh/core/appview/auth" 17 + "tangled.sh/tangled.sh/core/appview/db" 18 + "tangled.sh/tangled.sh/core/appview/email" 19 + "tangled.sh/tangled.sh/core/appview/middleware" 20 + "tangled.sh/tangled.sh/core/appview/pages" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + lexutil "github.com/bluesky-social/indigo/lex/util" 24 + "github.com/gliderlabs/ssh" 25 + "github.com/google/uuid" 26 + ) 27 + 28 + type Settings struct { 29 + Db *db.DB 30 + Auth *auth.Auth 31 + Pages *pages.Pages 32 + Config *appview.Config 33 + } 34 + 35 + func (s *Settings) Router() http.Handler { 36 + r := chi.NewRouter() 37 + 38 + r.Use(middleware.AuthMiddleware(s.Auth)) 39 + 40 + r.Get("/", s.settings) 41 + 42 + r.Route("/keys", func(r chi.Router) { 43 + r.Put("/", s.keys) 44 + r.Delete("/", s.keys) 45 + }) 46 + 47 + r.Route("/emails", func(r chi.Router) { 48 + r.Put("/", s.emails) 49 + r.Delete("/", s.emails) 50 + r.Get("/verify", s.emailsVerify) 51 + r.Post("/verify/resend", s.emailsVerifyResend) 52 + r.Post("/primary", s.emailsPrimary) 53 + }) 54 + 55 + return r 56 + } 57 + 58 + func (s *Settings) settings(w http.ResponseWriter, r *http.Request) { 59 + user := s.Auth.GetUser(r) 60 + pubKeys, err := db.GetPublicKeys(s.Db, user.Did) 61 + if err != nil { 62 + log.Println(err) 63 + } 64 + 65 + emails, err := db.GetAllEmails(s.Db, user.Did) 66 + if err != nil { 67 + log.Println(err) 68 + } 69 + 70 + s.Pages.Settings(w, pages.SettingsParams{ 71 + LoggedInUser: user, 72 + PubKeys: pubKeys, 73 + Emails: emails, 74 + }) 75 + } 76 + 77 + // buildVerificationEmail creates an email.Email struct for verification emails 78 + func (s *Settings) buildVerificationEmail(emailAddr, did, code string) email.Email { 79 + verifyURL := s.verifyUrl(did, emailAddr, code) 80 + 81 + return email.Email{ 82 + APIKey: s.Config.ResendApiKey, 83 + From: "noreply@notifs.tangled.sh", 84 + To: emailAddr, 85 + Subject: "Verify your Tangled email", 86 + Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 87 + ` + verifyURL, 88 + Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p> 89 + <p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`, 90 + } 91 + } 92 + 93 + // sendVerificationEmail handles the common logic for sending verification emails 94 + func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 95 + emailToSend := s.buildVerificationEmail(emailAddr, did, code) 96 + 97 + err := email.SendEmail(emailToSend) 98 + if err != nil { 99 + log.Printf("sending email: %s", err) 100 + s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 101 + return err 102 + } 103 + 104 + return nil 105 + } 106 + 107 + func (s *Settings) emails(w http.ResponseWriter, r *http.Request) { 108 + switch r.Method { 109 + case http.MethodGet: 110 + s.Pages.Notice(w, "settings-emails", "Unimplemented.") 111 + log.Println("unimplemented") 112 + return 113 + case http.MethodPut: 114 + did := s.Auth.GetDid(r) 115 + emAddr := r.FormValue("email") 116 + emAddr = strings.TrimSpace(emAddr) 117 + 118 + if !email.IsValidEmail(emAddr) { 119 + s.Pages.Notice(w, "settings-emails-error", "Invalid email address.") 120 + return 121 + } 122 + 123 + // check if email already exists in database 124 + existingEmail, err := db.GetEmail(s.Db, did, emAddr) 125 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 126 + log.Printf("checking for existing email: %s", err) 127 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 128 + return 129 + } 130 + 131 + if err == nil { 132 + if existingEmail.Verified { 133 + s.Pages.Notice(w, "settings-emails-error", "This email is already verified.") 134 + return 135 + } 136 + 137 + s.Pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.") 138 + return 139 + } 140 + 141 + code := uuid.New().String() 142 + 143 + // Begin transaction 144 + tx, err := s.Db.Begin() 145 + if err != nil { 146 + log.Printf("failed to start transaction: %s", err) 147 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 148 + return 149 + } 150 + defer tx.Rollback() 151 + 152 + if err := db.AddEmail(tx, db.Email{ 153 + Did: did, 154 + Address: emAddr, 155 + Verified: false, 156 + VerificationCode: code, 157 + }); err != nil { 158 + log.Printf("adding email: %s", err) 159 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 160 + return 161 + } 162 + 163 + if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 164 + return 165 + } 166 + 167 + // Commit transaction 168 + if err := tx.Commit(); err != nil { 169 + log.Printf("failed to commit transaction: %s", err) 170 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 171 + return 172 + } 173 + 174 + s.Pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.") 175 + return 176 + case http.MethodDelete: 177 + did := s.Auth.GetDid(r) 178 + emailAddr := r.FormValue("email") 179 + emailAddr = strings.TrimSpace(emailAddr) 180 + 181 + // Begin transaction 182 + tx, err := s.Db.Begin() 183 + if err != nil { 184 + log.Printf("failed to start transaction: %s", err) 185 + s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 186 + return 187 + } 188 + defer tx.Rollback() 189 + 190 + if err := db.DeleteEmail(tx, did, emailAddr); err != nil { 191 + log.Printf("deleting email: %s", err) 192 + s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 193 + return 194 + } 195 + 196 + // Commit transaction 197 + if err := tx.Commit(); err != nil { 198 + log.Printf("failed to commit transaction: %s", err) 199 + s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 200 + return 201 + } 202 + 203 + s.Pages.HxLocation(w, "/settings") 204 + return 205 + } 206 + } 207 + 208 + func (s *Settings) verifyUrl(did string, email string, code string) string { 209 + var appUrl string 210 + if s.Config.Dev { 211 + appUrl = "http://" + s.Config.ListenAddr 212 + } else { 213 + appUrl = "https://tangled.sh" 214 + } 215 + 216 + return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code)) 217 + } 218 + 219 + func (s *Settings) emailsVerify(w http.ResponseWriter, r *http.Request) { 220 + q := r.URL.Query() 221 + 222 + // Get the parameters directly from the query 223 + emailAddr := q.Get("email") 224 + did := q.Get("did") 225 + code := q.Get("code") 226 + 227 + valid, err := db.CheckValidVerificationCode(s.Db, did, emailAddr, code) 228 + if err != nil { 229 + log.Printf("checking email verification: %s", err) 230 + s.Pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.") 231 + return 232 + } 233 + 234 + if !valid { 235 + s.Pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.") 236 + return 237 + } 238 + 239 + // Mark email as verified in the database 240 + if err := db.MarkEmailVerified(s.Db, did, emailAddr); err != nil { 241 + log.Printf("marking email as verified: %s", err) 242 + s.Pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.") 243 + return 244 + } 245 + 246 + http.Redirect(w, r, "/settings", http.StatusSeeOther) 247 + } 248 + 249 + func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) { 250 + if r.Method != http.MethodPost { 251 + s.Pages.Notice(w, "settings-emails-error", "Invalid request method.") 252 + return 253 + } 254 + 255 + did := s.Auth.GetDid(r) 256 + emAddr := r.FormValue("email") 257 + emAddr = strings.TrimSpace(emAddr) 258 + 259 + if !email.IsValidEmail(emAddr) { 260 + s.Pages.Notice(w, "settings-emails-error", "Invalid email address.") 261 + return 262 + } 263 + 264 + // Check if email exists and is unverified 265 + existingEmail, err := db.GetEmail(s.Db, did, emAddr) 266 + if err != nil { 267 + if errors.Is(err, sql.ErrNoRows) { 268 + s.Pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.") 269 + } else { 270 + log.Printf("checking for existing email: %s", err) 271 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 272 + } 273 + return 274 + } 275 + 276 + if existingEmail.Verified { 277 + s.Pages.Notice(w, "settings-emails-error", "This email is already verified.") 278 + return 279 + } 280 + 281 + // Check if last verification email was sent less than 10 minutes ago 282 + if existingEmail.LastSent != nil { 283 + timeSinceLastSent := time.Since(*existingEmail.LastSent) 284 + if timeSinceLastSent < 10*time.Minute { 285 + waitTime := 10*time.Minute - timeSinceLastSent 286 + s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1))) 287 + return 288 + } 289 + } 290 + 291 + // Generate new verification code 292 + code := uuid.New().String() 293 + 294 + // Begin transaction 295 + tx, err := s.Db.Begin() 296 + if err != nil { 297 + log.Printf("failed to start transaction: %s", err) 298 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 299 + return 300 + } 301 + defer tx.Rollback() 302 + 303 + // Update the verification code and last sent time 304 + if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil { 305 + log.Printf("updating email verification: %s", err) 306 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 307 + return 308 + } 309 + 310 + // Send verification email 311 + if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 312 + return 313 + } 314 + 315 + // Commit transaction 316 + if err := tx.Commit(); err != nil { 317 + log.Printf("failed to commit transaction: %s", err) 318 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 319 + return 320 + } 321 + 322 + s.Pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.") 323 + } 324 + 325 + func (s *Settings) emailsPrimary(w http.ResponseWriter, r *http.Request) { 326 + did := s.Auth.GetDid(r) 327 + emailAddr := r.FormValue("email") 328 + emailAddr = strings.TrimSpace(emailAddr) 329 + 330 + if emailAddr == "" { 331 + s.Pages.Notice(w, "settings-emails-error", "Email address cannot be empty.") 332 + return 333 + } 334 + 335 + if err := db.MakeEmailPrimary(s.Db, did, emailAddr); err != nil { 336 + log.Printf("setting primary email: %s", err) 337 + s.Pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.") 338 + return 339 + } 340 + 341 + s.Pages.HxLocation(w, "/settings") 342 + } 343 + 344 + func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { 345 + switch r.Method { 346 + case http.MethodGet: 347 + s.Pages.Notice(w, "settings-keys", "Unimplemented.") 348 + log.Println("unimplemented") 349 + return 350 + case http.MethodPut: 351 + did := s.Auth.GetDid(r) 352 + key := r.FormValue("key") 353 + key = strings.TrimSpace(key) 354 + name := r.FormValue("name") 355 + client, _ := s.Auth.AuthorizedClient(r) 356 + 357 + _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) 358 + if err != nil { 359 + log.Printf("parsing public key: %s", err) 360 + s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 361 + return 362 + } 363 + 364 + rkey := appview.TID() 365 + 366 + tx, err := s.Db.Begin() 367 + if err != nil { 368 + log.Printf("failed to start tx; adding public key: %s", err) 369 + s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 370 + return 371 + } 372 + defer tx.Rollback() 373 + 374 + if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil { 375 + log.Printf("adding public key: %s", err) 376 + s.Pages.Notice(w, "settings-keys", "Failed to add public key.") 377 + return 378 + } 379 + 380 + // store in pds too 381 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 382 + Collection: tangled.PublicKeyNSID, 383 + Repo: did, 384 + Rkey: rkey, 385 + Record: &lexutil.LexiconTypeDecoder{ 386 + Val: &tangled.PublicKey{ 387 + Created: time.Now().Format(time.RFC3339), 388 + Key: key, 389 + Name: name, 390 + }}, 391 + }) 392 + // invalid record 393 + if err != nil { 394 + log.Printf("failed to create record: %s", err) 395 + s.Pages.Notice(w, "settings-keys", "Failed to create record.") 396 + return 397 + } 398 + 399 + log.Println("created atproto record: ", resp.Uri) 400 + 401 + err = tx.Commit() 402 + if err != nil { 403 + log.Printf("failed to commit tx; adding public key: %s", err) 404 + s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 405 + return 406 + } 407 + 408 + s.Pages.HxLocation(w, "/settings") 409 + return 410 + 411 + case http.MethodDelete: 412 + did := s.Auth.GetDid(r) 413 + q := r.URL.Query() 414 + 415 + name := q.Get("name") 416 + rkey := q.Get("rkey") 417 + key := q.Get("key") 418 + 419 + log.Println(name) 420 + log.Println(rkey) 421 + log.Println(key) 422 + 423 + client, _ := s.Auth.AuthorizedClient(r) 424 + 425 + if err := db.RemovePublicKey(s.Db, did, name, key); err != nil { 426 + log.Printf("removing public key: %s", err) 427 + s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") 428 + return 429 + } 430 + 431 + if rkey != "" { 432 + // remove from pds too 433 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 434 + Collection: tangled.PublicKeyNSID, 435 + Repo: did, 436 + Rkey: rkey, 437 + }) 438 + 439 + // invalid record 440 + if err != nil { 441 + log.Printf("failed to delete record from PDS: %s", err) 442 + s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.") 443 + return 444 + } 445 + } 446 + log.Println("deleted successfully") 447 + 448 + s.Pages.HxLocation(w, "/settings") 449 + return 450 + } 451 + }
+2 -1
appview/state/follow.go
··· 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 tangled "tangled.sh/tangled.sh/core/api/tangled" 11 "tangled.sh/tangled.sh/core/appview/db" 12 "tangled.sh/tangled.sh/core/appview/pages" 13 ) ··· 36 switch r.Method { 37 case http.MethodPost: 38 createdAt := time.Now().Format(time.RFC3339) 39 - rkey := s.TID() 40 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 41 Collection: tangled.GraphFollowNSID, 42 Repo: currentUser.Did,
··· 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 tangled "tangled.sh/tangled.sh/core/api/tangled" 11 + "tangled.sh/tangled.sh/core/appview" 12 "tangled.sh/tangled.sh/core/appview/db" 13 "tangled.sh/tangled.sh/core/appview/pages" 14 ) ··· 37 switch r.Method { 38 case http.MethodPost: 39 createdAt := time.Now().Format(time.RFC3339) 40 + rkey := appview.TID() 41 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 42 Collection: tangled.GraphFollowNSID, 43 Repo: currentUser.Did,
+7 -92
appview/state/middleware.go
··· 10 11 "slices" 12 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 "github.com/bluesky-social/indigo/atproto/identity" 15 - "github.com/bluesky-social/indigo/xrpc" 16 "github.com/go-chi/chi/v5" 17 - "tangled.sh/tangled.sh/core/appview" 18 - "tangled.sh/tangled.sh/core/appview/auth" 19 "tangled.sh/tangled.sh/core/appview/db" 20 ) 21 22 - type Middleware func(http.Handler) http.Handler 23 - 24 - func AuthMiddleware(s *State) Middleware { 25 - return func(next http.Handler) http.Handler { 26 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 27 - redirectFunc := func(w http.ResponseWriter, r *http.Request) { 28 - http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 29 - } 30 - if r.Header.Get("HX-Request") == "true" { 31 - redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 32 - w.Header().Set("HX-Redirect", "/login") 33 - w.WriteHeader(http.StatusOK) 34 - } 35 - } 36 - 37 - session, err := s.auth.GetSession(r) 38 - if session.IsNew || err != nil { 39 - log.Printf("not logged in, redirecting") 40 - redirectFunc(w, r) 41 - return 42 - } 43 - 44 - authorized, ok := session.Values[appview.SessionAuthenticated].(bool) 45 - if !ok || !authorized { 46 - log.Printf("not logged in, redirecting") 47 - redirectFunc(w, r) 48 - return 49 - } 50 - 51 - // refresh if nearing expiry 52 - // TODO: dedup with /login 53 - expiryStr := session.Values[appview.SessionExpiry].(string) 54 - expiry, err := time.Parse(time.RFC3339, expiryStr) 55 - if err != nil { 56 - log.Println("invalid expiry time", err) 57 - redirectFunc(w, r) 58 - return 59 - } 60 - pdsUrl, ok1 := session.Values[appview.SessionPds].(string) 61 - did, ok2 := session.Values[appview.SessionDid].(string) 62 - refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string) 63 - 64 - if !ok1 || !ok2 || !ok3 { 65 - log.Println("invalid expiry time", err) 66 - redirectFunc(w, r) 67 - return 68 - } 69 - 70 - if time.Now().After(expiry) { 71 - log.Println("token expired, refreshing ...") 72 - 73 - client := xrpc.Client{ 74 - Host: pdsUrl, 75 - Auth: &xrpc.AuthInfo{ 76 - Did: did, 77 - AccessJwt: refreshJwt, 78 - RefreshJwt: refreshJwt, 79 - }, 80 - } 81 - atSession, err := comatproto.ServerRefreshSession(r.Context(), &client) 82 - if err != nil { 83 - log.Println("failed to refresh session", err) 84 - redirectFunc(w, r) 85 - return 86 - } 87 - 88 - sessionish := auth.RefreshSessionWrapper{atSession} 89 - 90 - err = s.auth.StoreSession(r, w, &sessionish, pdsUrl) 91 - if err != nil { 92 - log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err) 93 - return 94 - } 95 - 96 - log.Println("successfully refreshed token") 97 - } 98 - 99 - next.ServeHTTP(w, r) 100 - }) 101 - } 102 - } 103 - 104 - func knotRoleMiddleware(s *State, group string) Middleware { 105 return func(next http.Handler) http.Handler { 106 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 107 // requires auth also ··· 131 } 132 } 133 134 - func KnotOwner(s *State) Middleware { 135 return knotRoleMiddleware(s, "server:owner") 136 } 137 138 - func RepoPermissionMiddleware(s *State, requiredPerm string) Middleware { 139 return func(next http.Handler) http.Handler { 140 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 141 // requires auth also ··· 175 }) 176 } 177 178 - func ResolveIdent(s *State) Middleware { 179 excluded := []string{"favicon.ico"} 180 181 return func(next http.Handler) http.Handler { ··· 201 } 202 } 203 204 - func ResolveRepo(s *State) Middleware { 205 return func(next http.Handler) http.Handler { 206 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 207 repoName := chi.URLParam(req, "repo") ··· 230 } 231 232 // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 233 - func ResolvePull(s *State) Middleware { 234 return func(next http.Handler) http.Handler { 235 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 236 f, err := fullyResolvedRepo(r)
··· 10 11 "slices" 12 13 "github.com/bluesky-social/indigo/atproto/identity" 14 "github.com/go-chi/chi/v5" 15 "tangled.sh/tangled.sh/core/appview/db" 16 + "tangled.sh/tangled.sh/core/appview/middleware" 17 ) 18 19 + func knotRoleMiddleware(s *State, group string) middleware.Middleware { 20 return func(next http.Handler) http.Handler { 21 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 // requires auth also ··· 46 } 47 } 48 49 + func KnotOwner(s *State) middleware.Middleware { 50 return knotRoleMiddleware(s, "server:owner") 51 } 52 53 + func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middleware { 54 return func(next http.Handler) http.Handler { 55 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 56 // requires auth also ··· 90 }) 91 } 92 93 + func ResolveIdent(s *State) middleware.Middleware { 94 excluded := []string{"favicon.ico"} 95 96 return func(next http.Handler) http.Handler { ··· 116 } 117 } 118 119 + func ResolveRepo(s *State) middleware.Middleware { 120 return func(next http.Handler) http.Handler { 121 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 122 repoName := chi.URLParam(req, "repo") ··· 145 } 146 147 // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 148 + func ResolvePull(s *State) middleware.Middleware { 149 return func(next http.Handler) http.Handler { 150 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 151 f, err := fullyResolvedRepo(r)
+13 -22
appview/state/pull.go
··· 13 "time" 14 15 "tangled.sh/tangled.sh/core/api/tangled" 16 "tangled.sh/tangled.sh/core/appview/auth" 17 "tangled.sh/tangled.sh/core/appview/db" 18 "tangled.sh/tangled.sh/core/appview/pages" ··· 120 resubmitResult = s.resubmitCheck(f, pull) 121 } 122 123 - var pullSourceRepo *db.Repo 124 - if pull.PullSource != nil { 125 - if pull.PullSource.RepoAt != nil { 126 - pullSourceRepo, err = db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 127 - if err != nil { 128 - log.Printf("failed to get repo by at uri: %v", err) 129 - return 130 - } 131 - } 132 - } 133 - 134 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 135 - LoggedInUser: user, 136 - RepoInfo: f.RepoInfo(s, user), 137 - DidHandleMap: didHandleMap, 138 - Pull: pull, 139 - PullSourceRepo: pullSourceRepo, 140 - MergeCheck: mergeCheckResponse, 141 - ResubmitCheck: resubmitResult, 142 }) 143 } 144 ··· 294 didHandleMap[identity.DID.String()] = identity.DID.String() 295 } 296 } 297 298 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 299 LoggedInUser: user, ··· 302 Pull: pull, 303 Round: roundIdInt, 304 Submission: pull.Submissions[roundIdInt], 305 - Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch), 306 }) 307 308 } ··· 533 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 534 Collection: tangled.RepoPullCommentNSID, 535 Repo: user.Did, 536 - Rkey: s.TID(), 537 Record: &lexutil.LexiconTypeDecoder{ 538 Val: &tangled.RepoPullComment{ 539 Repo: &atUri, ··· 804 sourceRev := comparison.Rev2 805 patch := comparison.Patch 806 807 - if patchutil.IsPatchValid(patch) { 808 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 809 return 810 } ··· 858 body = formatPatches[0].Body 859 } 860 861 - rkey := s.TID() 862 initialSubmission := db.PullSubmission{ 863 Patch: patch, 864 SourceRev: sourceRev,
··· 13 "time" 14 15 "tangled.sh/tangled.sh/core/api/tangled" 16 + "tangled.sh/tangled.sh/core/appview" 17 "tangled.sh/tangled.sh/core/appview/auth" 18 "tangled.sh/tangled.sh/core/appview/db" 19 "tangled.sh/tangled.sh/core/appview/pages" ··· 121 resubmitResult = s.resubmitCheck(f, pull) 122 } 123 124 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 125 + LoggedInUser: user, 126 + RepoInfo: f.RepoInfo(s, user), 127 + DidHandleMap: didHandleMap, 128 + Pull: pull, 129 + MergeCheck: mergeCheckResponse, 130 + ResubmitCheck: resubmitResult, 131 }) 132 } 133 ··· 283 didHandleMap[identity.DID.String()] = identity.DID.String() 284 } 285 } 286 + 287 + diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch) 288 289 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 290 LoggedInUser: user, ··· 293 Pull: pull, 294 Round: roundIdInt, 295 Submission: pull.Submissions[roundIdInt], 296 + Diff: &diff, 297 }) 298 299 } ··· 524 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 525 Collection: tangled.RepoPullCommentNSID, 526 Repo: user.Did, 527 + Rkey: appview.TID(), 528 Record: &lexutil.LexiconTypeDecoder{ 529 Val: &tangled.RepoPullComment{ 530 Repo: &atUri, ··· 795 sourceRev := comparison.Rev2 796 patch := comparison.Patch 797 798 + if !patchutil.IsPatchValid(patch) { 799 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 800 return 801 } ··· 849 body = formatPatches[0].Body 850 } 851 852 + rkey := appview.TID() 853 initialSubmission := db.PullSubmission{ 854 Patch: patch, 855 SourceRev: sourceRev,
+14 -5
appview/state/repo.go
··· 23 "github.com/go-chi/chi/v5" 24 "github.com/go-git/go-git/v5/plumbing" 25 "tangled.sh/tangled.sh/core/api/tangled" 26 "tangled.sh/tangled.sh/core/appview/auth" 27 "tangled.sh/tangled.sh/core/appview/db" 28 "tangled.sh/tangled.sh/core/appview/pages" 29 "tangled.sh/tangled.sh/core/appview/pages/markup" 30 "tangled.sh/tangled.sh/core/types" 31 32 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 1116 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1117 Collection: tangled.RepoIssueStateNSID, 1118 Repo: user.Did, 1119 - Rkey: s.TID(), 1120 Record: &lexutil.LexiconTypeDecoder{ 1121 Val: &tangled.RepoIssueState{ 1122 Issue: issue.IssueAt, ··· 1220 } 1221 1222 commentId := mathrand.IntN(1000000) 1223 - rkey := s.TID() 1224 1225 err := db.NewIssueComment(s.db, &db.Comment{ 1226 OwnerDid: user.Did, ··· 1558 isOpen = true 1559 } 1560 1561 user := s.auth.GetUser(r) 1562 f, err := fullyResolvedRepo(r) 1563 if err != nil { ··· 1565 return 1566 } 1567 1568 - issues, err := db.GetIssues(s.db, f.RepoAt, isOpen) 1569 if err != nil { 1570 log.Println("failed to get issues", err) 1571 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 1592 Issues: issues, 1593 DidHandleMap: didHandleMap, 1594 FilteringByOpen: isOpen, 1595 }) 1596 return 1597 } ··· 1650 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1651 Collection: tangled.RepoIssueNSID, 1652 Repo: user.Did, 1653 - Rkey: s.TID(), 1654 Record: &lexutil.LexiconTypeDecoder{ 1655 Val: &tangled.RepoIssue{ 1656 Repo: atUri, ··· 1754 sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1755 sourceAt := f.RepoAt.String() 1756 1757 - rkey := s.TID() 1758 repo := &db.Repo{ 1759 Did: user.Did, 1760 Name: forkName,
··· 23 "github.com/go-chi/chi/v5" 24 "github.com/go-git/go-git/v5/plumbing" 25 "tangled.sh/tangled.sh/core/api/tangled" 26 + "tangled.sh/tangled.sh/core/appview" 27 "tangled.sh/tangled.sh/core/appview/auth" 28 "tangled.sh/tangled.sh/core/appview/db" 29 "tangled.sh/tangled.sh/core/appview/pages" 30 "tangled.sh/tangled.sh/core/appview/pages/markup" 31 + "tangled.sh/tangled.sh/core/appview/pagination" 32 "tangled.sh/tangled.sh/core/types" 33 34 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 1118 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1119 Collection: tangled.RepoIssueStateNSID, 1120 Repo: user.Did, 1121 + Rkey: appview.TID(), 1122 Record: &lexutil.LexiconTypeDecoder{ 1123 Val: &tangled.RepoIssueState{ 1124 Issue: issue.IssueAt, ··· 1222 } 1223 1224 commentId := mathrand.IntN(1000000) 1225 + rkey := appview.TID() 1226 1227 err := db.NewIssueComment(s.db, &db.Comment{ 1228 OwnerDid: user.Did, ··· 1560 isOpen = true 1561 } 1562 1563 + page, ok := r.Context().Value("page").(pagination.Page) 1564 + if !ok { 1565 + log.Println("failed to get page") 1566 + page = pagination.FirstPage() 1567 + } 1568 + 1569 user := s.auth.GetUser(r) 1570 f, err := fullyResolvedRepo(r) 1571 if err != nil { ··· 1573 return 1574 } 1575 1576 + issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page) 1577 if err != nil { 1578 log.Println("failed to get issues", err) 1579 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 1600 Issues: issues, 1601 DidHandleMap: didHandleMap, 1602 FilteringByOpen: isOpen, 1603 + Page: page, 1604 }) 1605 return 1606 } ··· 1659 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1660 Collection: tangled.RepoIssueNSID, 1661 Repo: user.Did, 1662 + Rkey: appview.TID(), 1663 Record: &lexutil.LexiconTypeDecoder{ 1664 Val: &tangled.RepoIssue{ 1665 Repo: atUri, ··· 1763 sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1764 sourceAt := f.RepoAt.String() 1765 1766 + rkey := appview.TID() 1767 repo := &db.Repo{ 1768 Did: user.Did, 1769 Name: forkName,
+26 -23
appview/state/router.go
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 "tangled.sh/tangled.sh/core/appview/state/userutil" 9 ) 10 ··· 66 r.Get("/blob/{ref}/raw/*", s.RepoBlobRaw) 67 68 r.Route("/issues", func(r chi.Router) { 69 - r.Get("/", s.RepoIssues) 70 r.Get("/{issue}", s.RepoSingleIssue) 71 72 r.Group(func(r chi.Router) { 73 - r.Use(AuthMiddleware(s)) 74 r.Get("/new", s.NewIssue) 75 r.Post("/new", s.NewIssue) 76 r.Post("/{issue}/comment", s.NewIssueComment) ··· 86 }) 87 88 r.Route("/fork", func(r chi.Router) { 89 - r.Use(AuthMiddleware(s)) 90 r.Get("/", s.ForkRepo) 91 r.Post("/", s.ForkRepo) 92 }) 93 94 r.Route("/pulls", func(r chi.Router) { 95 r.Get("/", s.RepoPulls) 96 - r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) { 97 r.Get("/", s.NewPull) 98 r.Get("/patch-upload", s.PatchUploadFragment) 99 r.Post("/validate-patch", s.ValidatePatch) ··· 111 r.Get("/", s.RepoPullPatch) 112 r.Get("/interdiff", s.RepoPullInterdiff) 113 r.Get("/actions", s.PullActions) 114 - r.With(AuthMiddleware(s)).Route("/comment", func(r chi.Router) { 115 r.Get("/", s.PullComment) 116 r.Post("/", s.PullComment) 117 }) ··· 122 }) 123 124 r.Group(func(r chi.Router) { 125 - r.Use(AuthMiddleware(s)) 126 r.Route("/resubmit", func(r chi.Router) { 127 r.Get("/", s.ResubmitPull) 128 r.Post("/", s.ResubmitPull) ··· 145 146 // settings routes, needs auth 147 r.Group(func(r chi.Router) { 148 - r.Use(AuthMiddleware(s)) 149 // repo description can only be edited by owner 150 r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) { 151 r.Put("/", s.RepoDescription) ··· 176 177 r.Get("/", s.Timeline) 178 179 - r.With(AuthMiddleware(s)).Post("/logout", s.Logout) 180 181 r.Route("/login", func(r chi.Router) { 182 r.Get("/", s.Login) ··· 184 }) 185 186 r.Route("/knots", func(r chi.Router) { 187 - r.Use(AuthMiddleware(s)) 188 r.Get("/", s.Knots) 189 r.Post("/key", s.RegistrationKey) 190 ··· 202 203 r.Route("/repo", func(r chi.Router) { 204 r.Route("/new", func(r chi.Router) { 205 - r.Use(AuthMiddleware(s)) 206 r.Get("/", s.NewRepo) 207 r.Post("/", s.NewRepo) 208 }) 209 // r.Post("/import", s.ImportRepo) 210 }) 211 212 - r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) { 213 r.Post("/", s.Follow) 214 r.Delete("/", s.Follow) 215 }) 216 217 - r.With(AuthMiddleware(s)).Route("/star", func(r chi.Router) { 218 r.Post("/", s.Star) 219 r.Delete("/", s.Star) 220 }) 221 222 - r.Route("/settings", func(r chi.Router) { 223 - r.Use(AuthMiddleware(s)) 224 - r.Get("/", s.Settings) 225 - r.Put("/keys", s.SettingsKeys) 226 - r.Delete("/keys", s.SettingsKeys) 227 - r.Put("/emails", s.SettingsEmails) 228 - r.Delete("/emails", s.SettingsEmails) 229 - r.Get("/emails/verify", s.SettingsEmailsVerify) 230 - r.Post("/emails/verify/resend", s.SettingsEmailsVerifyResend) 231 - r.Post("/emails/primary", s.SettingsEmailsPrimary) 232 - }) 233 234 r.Get("/keys/{user}", s.Keys) 235 ··· 238 }) 239 return r 240 }
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 + "tangled.sh/tangled.sh/core/appview/middleware" 9 + "tangled.sh/tangled.sh/core/appview/settings" 10 "tangled.sh/tangled.sh/core/appview/state/userutil" 11 ) 12 ··· 68 r.Get("/blob/{ref}/raw/*", s.RepoBlobRaw) 69 70 r.Route("/issues", func(r chi.Router) { 71 + r.With(middleware.Paginate).Get("/", s.RepoIssues) 72 r.Get("/{issue}", s.RepoSingleIssue) 73 74 r.Group(func(r chi.Router) { 75 + r.Use(middleware.AuthMiddleware(s.auth)) 76 r.Get("/new", s.NewIssue) 77 r.Post("/new", s.NewIssue) 78 r.Post("/{issue}/comment", s.NewIssueComment) ··· 88 }) 89 90 r.Route("/fork", func(r chi.Router) { 91 + r.Use(middleware.AuthMiddleware(s.auth)) 92 r.Get("/", s.ForkRepo) 93 r.Post("/", s.ForkRepo) 94 }) 95 96 r.Route("/pulls", func(r chi.Router) { 97 r.Get("/", s.RepoPulls) 98 + r.With(middleware.AuthMiddleware(s.auth)).Route("/new", func(r chi.Router) { 99 r.Get("/", s.NewPull) 100 r.Get("/patch-upload", s.PatchUploadFragment) 101 r.Post("/validate-patch", s.ValidatePatch) ··· 113 r.Get("/", s.RepoPullPatch) 114 r.Get("/interdiff", s.RepoPullInterdiff) 115 r.Get("/actions", s.PullActions) 116 + r.With(middleware.AuthMiddleware(s.auth)).Route("/comment", func(r chi.Router) { 117 r.Get("/", s.PullComment) 118 r.Post("/", s.PullComment) 119 }) ··· 124 }) 125 126 r.Group(func(r chi.Router) { 127 + r.Use(middleware.AuthMiddleware(s.auth)) 128 r.Route("/resubmit", func(r chi.Router) { 129 r.Get("/", s.ResubmitPull) 130 r.Post("/", s.ResubmitPull) ··· 147 148 // settings routes, needs auth 149 r.Group(func(r chi.Router) { 150 + r.Use(middleware.AuthMiddleware(s.auth)) 151 // repo description can only be edited by owner 152 r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) { 153 r.Put("/", s.RepoDescription) ··· 178 179 r.Get("/", s.Timeline) 180 181 + r.With(middleware.AuthMiddleware(s.auth)).Post("/logout", s.Logout) 182 183 r.Route("/login", func(r chi.Router) { 184 r.Get("/", s.Login) ··· 186 }) 187 188 r.Route("/knots", func(r chi.Router) { 189 + r.Use(middleware.AuthMiddleware(s.auth)) 190 r.Get("/", s.Knots) 191 r.Post("/key", s.RegistrationKey) 192 ··· 204 205 r.Route("/repo", func(r chi.Router) { 206 r.Route("/new", func(r chi.Router) { 207 + r.Use(middleware.AuthMiddleware(s.auth)) 208 r.Get("/", s.NewRepo) 209 r.Post("/", s.NewRepo) 210 }) 211 // r.Post("/import", s.ImportRepo) 212 }) 213 214 + r.With(middleware.AuthMiddleware(s.auth)).Route("/follow", func(r chi.Router) { 215 r.Post("/", s.Follow) 216 r.Delete("/", s.Follow) 217 }) 218 219 + r.With(middleware.AuthMiddleware(s.auth)).Route("/star", func(r chi.Router) { 220 r.Post("/", s.Star) 221 r.Delete("/", s.Star) 222 }) 223 224 + r.Mount("/settings", s.SettingsRouter()) 225 226 r.Get("/keys/{user}", s.Keys) 227 ··· 230 }) 231 return r 232 } 233 + 234 + func (s *State) SettingsRouter() http.Handler { 235 + settings := &settings.Settings{ 236 + Db: s.db, 237 + Auth: s.auth, 238 + Pages: s.pages, 239 + Config: s.config, 240 + } 241 + 242 + return settings.Router() 243 + }
-416
appview/state/settings.go
··· 1 - package state 2 - 3 - import ( 4 - "database/sql" 5 - "errors" 6 - "fmt" 7 - "log" 8 - "net/http" 9 - "net/url" 10 - "strings" 11 - "time" 12 - 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - "github.com/gliderlabs/ssh" 16 - "github.com/google/uuid" 17 - "tangled.sh/tangled.sh/core/api/tangled" 18 - "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/email" 20 - "tangled.sh/tangled.sh/core/appview/pages" 21 - ) 22 - 23 - func (s *State) Settings(w http.ResponseWriter, r *http.Request) { 24 - user := s.auth.GetUser(r) 25 - pubKeys, err := db.GetPublicKeys(s.db, user.Did) 26 - if err != nil { 27 - log.Println(err) 28 - } 29 - 30 - emails, err := db.GetAllEmails(s.db, user.Did) 31 - if err != nil { 32 - log.Println(err) 33 - } 34 - 35 - s.pages.Settings(w, pages.SettingsParams{ 36 - LoggedInUser: user, 37 - PubKeys: pubKeys, 38 - Emails: emails, 39 - }) 40 - } 41 - 42 - // buildVerificationEmail creates an email.Email struct for verification emails 43 - func (s *State) buildVerificationEmail(emailAddr, did, code string) email.Email { 44 - verifyURL := s.verifyUrl(did, emailAddr, code) 45 - 46 - return email.Email{ 47 - APIKey: s.config.ResendApiKey, 48 - From: "noreply@notifs.tangled.sh", 49 - To: emailAddr, 50 - Subject: "Verify your Tangled email", 51 - Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 52 - ` + verifyURL, 53 - Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p> 54 - <p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`, 55 - } 56 - } 57 - 58 - // sendVerificationEmail handles the common logic for sending verification emails 59 - func (s *State) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 60 - emailToSend := s.buildVerificationEmail(emailAddr, did, code) 61 - 62 - err := email.SendEmail(emailToSend) 63 - if err != nil { 64 - log.Printf("sending email: %s", err) 65 - s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 66 - return err 67 - } 68 - 69 - return nil 70 - } 71 - 72 - func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) { 73 - switch r.Method { 74 - case http.MethodGet: 75 - s.pages.Notice(w, "settings-emails", "Unimplemented.") 76 - log.Println("unimplemented") 77 - return 78 - case http.MethodPut: 79 - did := s.auth.GetDid(r) 80 - emAddr := r.FormValue("email") 81 - emAddr = strings.TrimSpace(emAddr) 82 - 83 - if !email.IsValidEmail(emAddr) { 84 - s.pages.Notice(w, "settings-emails-error", "Invalid email address.") 85 - return 86 - } 87 - 88 - // check if email already exists in database 89 - existingEmail, err := db.GetEmail(s.db, did, emAddr) 90 - if err != nil && !errors.Is(err, sql.ErrNoRows) { 91 - log.Printf("checking for existing email: %s", err) 92 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 93 - return 94 - } 95 - 96 - if err == nil { 97 - if existingEmail.Verified { 98 - s.pages.Notice(w, "settings-emails-error", "This email is already verified.") 99 - return 100 - } 101 - 102 - s.pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.") 103 - return 104 - } 105 - 106 - code := uuid.New().String() 107 - 108 - // Begin transaction 109 - tx, err := s.db.Begin() 110 - if err != nil { 111 - log.Printf("failed to start transaction: %s", err) 112 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 113 - return 114 - } 115 - defer tx.Rollback() 116 - 117 - if err := db.AddEmail(tx, db.Email{ 118 - Did: did, 119 - Address: emAddr, 120 - Verified: false, 121 - VerificationCode: code, 122 - }); err != nil { 123 - log.Printf("adding email: %s", err) 124 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 125 - return 126 - } 127 - 128 - if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 129 - return 130 - } 131 - 132 - // Commit transaction 133 - if err := tx.Commit(); err != nil { 134 - log.Printf("failed to commit transaction: %s", err) 135 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 136 - return 137 - } 138 - 139 - s.pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.") 140 - return 141 - case http.MethodDelete: 142 - did := s.auth.GetDid(r) 143 - emailAddr := r.FormValue("email") 144 - emailAddr = strings.TrimSpace(emailAddr) 145 - 146 - // Begin transaction 147 - tx, err := s.db.Begin() 148 - if err != nil { 149 - log.Printf("failed to start transaction: %s", err) 150 - s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 151 - return 152 - } 153 - defer tx.Rollback() 154 - 155 - if err := db.DeleteEmail(tx, did, emailAddr); err != nil { 156 - log.Printf("deleting email: %s", err) 157 - s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 158 - return 159 - } 160 - 161 - // Commit transaction 162 - if err := tx.Commit(); err != nil { 163 - log.Printf("failed to commit transaction: %s", err) 164 - s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 165 - return 166 - } 167 - 168 - s.pages.HxLocation(w, "/settings") 169 - return 170 - } 171 - } 172 - 173 - func (s *State) verifyUrl(did string, email string, code string) string { 174 - var appUrl string 175 - if s.config.Dev { 176 - appUrl = "http://" + s.config.ListenAddr 177 - } else { 178 - appUrl = "https://tangled.sh" 179 - } 180 - 181 - return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code)) 182 - } 183 - 184 - func (s *State) SettingsEmailsVerify(w http.ResponseWriter, r *http.Request) { 185 - q := r.URL.Query() 186 - 187 - // Get the parameters directly from the query 188 - emailAddr := q.Get("email") 189 - did := q.Get("did") 190 - code := q.Get("code") 191 - 192 - valid, err := db.CheckValidVerificationCode(s.db, did, emailAddr, code) 193 - if err != nil { 194 - log.Printf("checking email verification: %s", err) 195 - s.pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.") 196 - return 197 - } 198 - 199 - if !valid { 200 - s.pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.") 201 - return 202 - } 203 - 204 - // Mark email as verified in the database 205 - if err := db.MarkEmailVerified(s.db, did, emailAddr); err != nil { 206 - log.Printf("marking email as verified: %s", err) 207 - s.pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.") 208 - return 209 - } 210 - 211 - http.Redirect(w, r, "/settings", http.StatusSeeOther) 212 - } 213 - 214 - func (s *State) SettingsEmailsVerifyResend(w http.ResponseWriter, r *http.Request) { 215 - if r.Method != http.MethodPost { 216 - s.pages.Notice(w, "settings-emails-error", "Invalid request method.") 217 - return 218 - } 219 - 220 - did := s.auth.GetDid(r) 221 - emAddr := r.FormValue("email") 222 - emAddr = strings.TrimSpace(emAddr) 223 - 224 - if !email.IsValidEmail(emAddr) { 225 - s.pages.Notice(w, "settings-emails-error", "Invalid email address.") 226 - return 227 - } 228 - 229 - // Check if email exists and is unverified 230 - existingEmail, err := db.GetEmail(s.db, did, emAddr) 231 - if err != nil { 232 - if errors.Is(err, sql.ErrNoRows) { 233 - s.pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.") 234 - } else { 235 - log.Printf("checking for existing email: %s", err) 236 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 237 - } 238 - return 239 - } 240 - 241 - if existingEmail.Verified { 242 - s.pages.Notice(w, "settings-emails-error", "This email is already verified.") 243 - return 244 - } 245 - 246 - // Check if last verification email was sent less than 10 minutes ago 247 - if existingEmail.LastSent != nil { 248 - timeSinceLastSent := time.Since(*existingEmail.LastSent) 249 - if timeSinceLastSent < 10*time.Minute { 250 - waitTime := 10*time.Minute - timeSinceLastSent 251 - s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1))) 252 - return 253 - } 254 - } 255 - 256 - // Generate new verification code 257 - code := uuid.New().String() 258 - 259 - // Begin transaction 260 - tx, err := s.db.Begin() 261 - if err != nil { 262 - log.Printf("failed to start transaction: %s", err) 263 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 264 - return 265 - } 266 - defer tx.Rollback() 267 - 268 - // Update the verification code and last sent time 269 - if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil { 270 - log.Printf("updating email verification: %s", err) 271 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 272 - return 273 - } 274 - 275 - // Send verification email 276 - if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 277 - return 278 - } 279 - 280 - // Commit transaction 281 - if err := tx.Commit(); err != nil { 282 - log.Printf("failed to commit transaction: %s", err) 283 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 284 - return 285 - } 286 - 287 - s.pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.") 288 - } 289 - 290 - func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) { 291 - did := s.auth.GetDid(r) 292 - emailAddr := r.FormValue("email") 293 - emailAddr = strings.TrimSpace(emailAddr) 294 - 295 - if emailAddr == "" { 296 - s.pages.Notice(w, "settings-emails-error", "Email address cannot be empty.") 297 - return 298 - } 299 - 300 - if err := db.MakeEmailPrimary(s.db, did, emailAddr); err != nil { 301 - log.Printf("setting primary email: %s", err) 302 - s.pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.") 303 - return 304 - } 305 - 306 - s.pages.HxLocation(w, "/settings") 307 - } 308 - 309 - func (s *State) SettingsKeys(w http.ResponseWriter, r *http.Request) { 310 - switch r.Method { 311 - case http.MethodGet: 312 - s.pages.Notice(w, "settings-keys", "Unimplemented.") 313 - log.Println("unimplemented") 314 - return 315 - case http.MethodPut: 316 - did := s.auth.GetDid(r) 317 - key := r.FormValue("key") 318 - key = strings.TrimSpace(key) 319 - name := r.FormValue("name") 320 - client, _ := s.auth.AuthorizedClient(r) 321 - 322 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) 323 - if err != nil { 324 - log.Printf("parsing public key: %s", err) 325 - s.pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 326 - return 327 - } 328 - 329 - rkey := s.TID() 330 - 331 - tx, err := s.db.Begin() 332 - if err != nil { 333 - log.Printf("failed to start tx; adding public key: %s", err) 334 - s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 335 - return 336 - } 337 - defer tx.Rollback() 338 - 339 - if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil { 340 - log.Printf("adding public key: %s", err) 341 - s.pages.Notice(w, "settings-keys", "Failed to add public key.") 342 - return 343 - } 344 - 345 - // store in pds too 346 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 347 - Collection: tangled.PublicKeyNSID, 348 - Repo: did, 349 - Rkey: rkey, 350 - Record: &lexutil.LexiconTypeDecoder{ 351 - Val: &tangled.PublicKey{ 352 - Created: time.Now().Format(time.RFC3339), 353 - Key: key, 354 - Name: name, 355 - }}, 356 - }) 357 - // invalid record 358 - if err != nil { 359 - log.Printf("failed to create record: %s", err) 360 - s.pages.Notice(w, "settings-keys", "Failed to create record.") 361 - return 362 - } 363 - 364 - log.Println("created atproto record: ", resp.Uri) 365 - 366 - err = tx.Commit() 367 - if err != nil { 368 - log.Printf("failed to commit tx; adding public key: %s", err) 369 - s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 370 - return 371 - } 372 - 373 - s.pages.HxLocation(w, "/settings") 374 - return 375 - 376 - case http.MethodDelete: 377 - did := s.auth.GetDid(r) 378 - q := r.URL.Query() 379 - 380 - name := q.Get("name") 381 - rkey := q.Get("rkey") 382 - key := q.Get("key") 383 - 384 - log.Println(name) 385 - log.Println(rkey) 386 - log.Println(key) 387 - 388 - client, _ := s.auth.AuthorizedClient(r) 389 - 390 - if err := db.RemovePublicKey(s.db, did, name, key); err != nil { 391 - log.Printf("removing public key: %s", err) 392 - s.pages.Notice(w, "settings-keys", "Failed to remove public key.") 393 - return 394 - } 395 - 396 - if rkey != "" { 397 - // remove from pds too 398 - _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 399 - Collection: tangled.PublicKeyNSID, 400 - Repo: did, 401 - Rkey: rkey, 402 - }) 403 - 404 - // invalid record 405 - if err != nil { 406 - log.Printf("failed to delete record from PDS: %s", err) 407 - s.pages.Notice(w, "settings-keys", "Failed to remove key from PDS.") 408 - return 409 - } 410 - } 411 - log.Println("deleted successfully") 412 - 413 - s.pages.HxLocation(w, "/settings") 414 - return 415 - } 416 - }
···
+2 -1
appview/state/star.go
··· 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 tangled "tangled.sh/tangled.sh/core/api/tangled" 12 "tangled.sh/tangled.sh/core/appview/db" 13 "tangled.sh/tangled.sh/core/appview/pages" 14 ) ··· 33 switch r.Method { 34 case http.MethodPost: 35 createdAt := time.Now().Format(time.RFC3339) 36 - rkey := s.TID() 37 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 38 Collection: tangled.FeedStarNSID, 39 Repo: currentUser.Did,
··· 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 tangled "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/appview" 13 "tangled.sh/tangled.sh/core/appview/db" 14 "tangled.sh/tangled.sh/core/appview/pages" 15 ) ··· 34 switch r.Method { 35 case http.MethodPost: 36 createdAt := time.Now().Format(time.RFC3339) 37 + rkey := appview.TID() 38 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 39 Collection: tangled.FeedStarNSID, 40 Repo: currentUser.Did,
+5 -5
appview/state/state.go
··· 55 56 clock := syntax.NewTIDClock(0) 57 58 - pgs := pages.NewPages() 59 60 resolver := appview.NewResolver() 61 ··· 91 return state, nil 92 } 93 94 - func (s *State) TID() string { 95 - return s.tidClock.Next().String() 96 } 97 98 func (s *State) Login(w http.ResponseWriter, r *http.Request) { ··· 522 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 523 Collection: tangled.KnotMemberNSID, 524 Repo: currentUser.Did, 525 - Rkey: s.TID(), 526 Record: &lexutil.LexiconTypeDecoder{ 527 Val: &tangled.KnotMember{ 528 Member: memberIdent.DID.String(), ··· 646 return 647 } 648 649 - rkey := s.TID() 650 repo := &db.Repo{ 651 Did: user.Did, 652 Name: repoName,
··· 55 56 clock := syntax.NewTIDClock(0) 57 58 + pgs := pages.NewPages(config.Dev) 59 60 resolver := appview.NewResolver() 61 ··· 91 return state, nil 92 } 93 94 + func TID(c *syntax.TIDClock) string { 95 + return c.Next().String() 96 } 97 98 func (s *State) Login(w http.ResponseWriter, r *http.Request) { ··· 522 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 523 Collection: tangled.KnotMemberNSID, 524 Repo: currentUser.Did, 525 + Rkey: appview.TID(), 526 Record: &lexutil.LexiconTypeDecoder{ 527 Val: &tangled.KnotMember{ 528 Member: memberIdent.DID.String(), ··· 646 return 647 } 648 649 + rkey := appview.TID() 650 repo := &db.Repo{ 651 Did: user.Did, 652 Name: repoName,
+11
appview/tid.go
···
··· 1 + package appview 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/syntax" 5 + ) 6 + 7 + var c *syntax.TIDClock = syntax.NewTIDClock(0) 8 + 9 + func TID() string { 10 + return c.Next().String() 11 + }
+9 -7
docs/contributing.md
··· 11 ### message format 12 13 ``` 14 - <service/top-level directory>: <package/path>: <short summary of change> 15 16 17 - Optional longer description, if needed. Explain what the change does and 18 - why, especially if not obvious. Reference relevant issues or PRs when 19 - applicable. These can be links for now since we don't auto-link 20 - issues/PRs yet. 21 ``` 22 23 Here are some examples: ··· 35 36 ### general notes 37 38 - - PRs get merged as a single commit, so keep PRs small and focused. Use 39 - the above guidelines for the PR title and description. 40 - Use the imperative mood in the summary line (e.g., "fix bug" not 41 "fixed bug" or "fixes bug"). 42 - Try to keep the summary line under 72 characters, but we aren't too
··· 11 ### message format 12 13 ``` 14 + <service/top-level directory>: <affected package/directory>: <short summary of change> 15 16 17 + Optional longer description can go here, if necessary. Explain what the 18 + change does and why, especially if not obvious. Reference relevant 19 + issues or PRs when applicable. These can be links for now since we don't 20 + auto-link issues/PRs yet. 21 ``` 22 23 Here are some examples: ··· 35 36 ### general notes 37 38 + - PRs get merged "as-is" (fast-forward) -- like applying a patch-series 39 + using `git am`. At present, there is no squashing -- so please author 40 + your commits as they would appear on `master`, following the above 41 + guidelines. 42 - Use the imperative mood in the summary line (e.g., "fix bug" not 43 "fixed bug" or "fixes bug"). 44 - Try to keep the summary line under 72 characters, but we aren't too
+82
docs/knot-hosting.md
··· 106 107 You should now have a running knot server! You can finalize your registration by hitting the 108 `initialize` button on the [/knots](/knots) page.
··· 106 107 You should now have a running knot server! You can finalize your registration by hitting the 108 `initialize` button on the [/knots](/knots) page. 109 + 110 + ### custom paths 111 + 112 + (This section applies to manual setup only. Docker users should edit the mounts 113 + in `docker-compose.yml` instead.) 114 + 115 + Right now, the database and repositories of your knot lives in `/home/git`. You 116 + can move these paths if you'd like to store them in another folder. Be careful 117 + when adjusting these paths: 118 + 119 + * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 120 + any possible side effects. Remember to restart it once you're done. 121 + * Make backups before moving in case something goes wrong. 122 + * Make sure the `git` user can read and write from the new paths. 123 + 124 + #### database 125 + 126 + As an example, let's say the current database is at `/home/git/knotserver.db`, 127 + and we want to move it to `/home/git/database/knotserver.db`. 128 + 129 + Copy the current database to the new location. Make sure to copy the `.db-shm` 130 + and `.db-wal` files if they exist. 131 + 132 + ``` 133 + mkdir /home/git/database 134 + cp /home/git/knotserver.db* /home/git/database 135 + ``` 136 + 137 + In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 138 + the new file path (_not_ the directory): 139 + 140 + ``` 141 + KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 142 + ``` 143 + 144 + #### repositories 145 + 146 + As an example, let's say the repositories are currently in `/home/git`, and we 147 + want to move them into `/home/git/repositories`. 148 + 149 + Create the new folder, then move the existing repositories (if there are any): 150 + 151 + ``` 152 + mkdir /home/git/repositories 153 + # move all DIDs into the new folder; these will vary for you! 154 + mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 155 + ``` 156 + 157 + In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 158 + to the new directory: 159 + 160 + ``` 161 + KNOT_REPO_SCAN_PATH=/home/git/repositories 162 + ``` 163 + 164 + In your SSH config (e.g. `/etc/ssh/sshd_config.d/authorized_keys_command.conf`), 165 + update the `AuthorizedKeysCommand` line to use the new folder. For example: 166 + 167 + ``` 168 + Match User git 169 + AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -git-dir /home/git/repositories 170 + AuthorizedKeysCommandUser nobody 171 + ``` 172 + 173 + Make sure to restart your SSH server! 174 + 175 + #### git 176 + 177 + The keyfetch executable takes multiple arguments to change certain paths. You 178 + can view a full list by running `/usr/local/libexec/tangled-keyfetch -h`. 179 + 180 + As an example, if you wanted to change the path to the repoguard executable, 181 + you would edit your SSH config (e.g. `/etc/ssh/sshd_config.d/authorized_keys_command.conf`) 182 + and update the `AuthorizedKeysCommand` line: 183 + 184 + ``` 185 + Match User git 186 + AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -repoguard-path /path/to/repoguard 187 + AuthorizedKeysCommandUser nobody 188 + ``` 189 + 190 + Make sure to restart your SSH server!
+3 -3
flake.lock
··· 48 "indigo": { 49 "flake": false, 50 "locked": { 51 - "lastModified": 1738491661, 52 - "narHash": "sha256-+njDigkvjH4XmXZMog5Mp0K4x9mamHX6gSGJCZB9mE4=", 53 "owner": "oppiliappan", 54 "repo": "indigo", 55 - "rev": "feb802f02a462ac0a6392ffc3e40b0529f0cdf71", 56 "type": "github" 57 }, 58 "original": {
··· 48 "indigo": { 49 "flake": false, 50 "locked": { 51 + "lastModified": 1745333930, 52 + "narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=", 53 "owner": "oppiliappan", 54 "repo": "indigo", 55 + "rev": "e4e59280737b8676611fc077a228d47b3e8e9491", 56 "type": "github" 57 }, 58 "original": {
+10 -1
flake.nix
··· 173 ${pkgs.air}/bin/air -c /dev/null \ 174 -build.cmd "${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o ./appview/pages/static/tw.css && ${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 175 -build.bin "./out/${name}.out" \ 176 - -build.include_ext "go,html,css" 177 ''; 178 in { 179 watch-appview = { ··· 183 watch-knotserver = { 184 type = "app"; 185 program = ''${air-watcher "knotserver"}/bin/run''; 186 }; 187 }); 188
··· 173 ${pkgs.air}/bin/air -c /dev/null \ 174 -build.cmd "${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o ./appview/pages/static/tw.css && ${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 175 -build.bin "./out/${name}.out" \ 176 + -build.include_ext "go" 177 + ''; 178 + tailwind-watcher = 179 + pkgs.writeShellScriptBin "run" 180 + '' 181 + ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 182 ''; 183 in { 184 watch-appview = { ··· 188 watch-knotserver = { 189 type = "app"; 190 program = ''${air-watcher "knotserver"}/bin/run''; 191 + }; 192 + watch-tailwind = { 193 + type = "app"; 194 + program = ''${tailwind-watcher}/bin/run''; 195 }; 196 }); 197
+4 -4
input.css
··· 162 /* CommentSpecial */ .chroma .cs { color: #9ca0b0; font-style: italic } 163 /* CommentPreproc */ .chroma .cp { color: #9ca0b0; font-style: italic } 164 /* CommentPreprocFile */ .chroma .cpf { color: #9ca0b0; font-weight: bold; font-style: italic } 165 - /* GenericDeleted */ .chroma .gd { color: #d20f39; background-color: #ccd0da } 166 /* GenericEmph */ .chroma .ge { font-style: italic } 167 /* GenericError */ .chroma .gr { color: #d20f39 } 168 /* GenericHeading */ .chroma .gh { color: #fe640b; font-weight: bold } 169 - /* GenericInserted */ .chroma .gi { color: #40a02b; background-color: #ccd0da } 170 /* GenericStrong */ .chroma .gs { font-weight: bold } 171 /* GenericSubheading */ .chroma .gu { color: #fe640b; font-weight: bold } 172 /* GenericTraceback */ .chroma .gt { color: #d20f39 } ··· 239 /* CommentSpecial */ .chroma .cs { color: #6e738d; font-style: italic } 240 /* CommentPreproc */ .chroma .cp { color: #6e738d; font-style: italic } 241 /* CommentPreprocFile */ .chroma .cpf { color: #6e738d; font-weight: bold; font-style: italic } 242 - /* GenericDeleted */ .chroma .gd { color: #ed8796; background-color: #363a4f } 243 /* GenericEmph */ .chroma .ge { font-style: italic } 244 /* GenericError */ .chroma .gr { color: #ed8796 } 245 /* GenericHeading */ .chroma .gh { color: #f5a97f; font-weight: bold } 246 - /* GenericInserted */ .chroma .gi { color: #a6da95; background-color: #363a4f } 247 /* GenericStrong */ .chroma .gs { font-weight: bold } 248 /* GenericSubheading */ .chroma .gu { color: #f5a97f; font-weight: bold } 249 /* GenericTraceback */ .chroma .gt { color: #ed8796 }
··· 162 /* CommentSpecial */ .chroma .cs { color: #9ca0b0; font-style: italic } 163 /* CommentPreproc */ .chroma .cp { color: #9ca0b0; font-style: italic } 164 /* CommentPreprocFile */ .chroma .cpf { color: #9ca0b0; font-weight: bold; font-style: italic } 165 + /* GenericDeleted */ .chroma .gd { color: #d20f39; background-color: oklch(93.6% 0.032 17.717) } 166 /* GenericEmph */ .chroma .ge { font-style: italic } 167 /* GenericError */ .chroma .gr { color: #d20f39 } 168 /* GenericHeading */ .chroma .gh { color: #fe640b; font-weight: bold } 169 + /* GenericInserted */ .chroma .gi { color: #40a02b; background-color: oklch(96.2% 0.044 156.743) } 170 /* GenericStrong */ .chroma .gs { font-weight: bold } 171 /* GenericSubheading */ .chroma .gu { color: #fe640b; font-weight: bold } 172 /* GenericTraceback */ .chroma .gt { color: #d20f39 } ··· 239 /* CommentSpecial */ .chroma .cs { color: #6e738d; font-style: italic } 240 /* CommentPreproc */ .chroma .cp { color: #6e738d; font-style: italic } 241 /* CommentPreprocFile */ .chroma .cpf { color: #6e738d; font-weight: bold; font-style: italic } 242 + /* GenericDeleted */ .chroma .gd { color: #ed8796; background-color: oklch(44.4% 0.177 26.899 / 0.5) } 243 /* GenericEmph */ .chroma .ge { font-style: italic } 244 /* GenericError */ .chroma .gr { color: #ed8796 } 245 /* GenericHeading */ .chroma .gh { color: #f5a97f; font-weight: bold } 246 + /* GenericInserted */ .chroma .gi { color: #a6da95; background-color: oklch(44.8% 0.119 151.328 / 0.5) } 247 /* GenericStrong */ .chroma .gs { font-weight: bold } 248 /* GenericSubheading */ .chroma .gu { color: #f5a97f; font-weight: bold } 249 /* GenericTraceback */ .chroma .gt { color: #ed8796 }
+8
patchutil/interdiff.go
··· 11 Files []*InterdiffFile 12 } 13 14 func (i *InterdiffResult) String() string { 15 var b strings.Builder 16 for _, f := range i.Files {
··· 11 Files []*InterdiffFile 12 } 13 14 + func (i *InterdiffResult) AffectedFiles() []string { 15 + files := make([]string, len(i.Files)) 16 + for _, f := range i.Files { 17 + files = append(files, f.Name) 18 + } 19 + return files 20 + } 21 + 22 func (i *InterdiffResult) String() string { 23 var b strings.Builder 24 for _, f := range i.Files {
+14
types/diff.go
··· 59 Patch string `json:"patch"` 60 Diff []*gitdiff.File `json:"diff"` 61 }
··· 59 Patch string `json:"patch"` 60 Diff []*gitdiff.File `json:"diff"` 61 } 62 + 63 + func (d *NiceDiff) ChangedFiles() []string { 64 + files := make([]string, len(d.Diff)) 65 + 66 + for i, f := range d.Diff { 67 + if f.IsDelete { 68 + files[i] = f.Name.Old 69 + } else { 70 + files[i] = f.Name.New 71 + } 72 + } 73 + 74 + return files 75 + }