Monorepo for Tangled tangled.org

appview: lookup emails to dids/handles in commits

Changed files
+209 -117
appview
+45 -1
appview/db/email.go
··· 1 package db 2 3 - import "time" 4 5 type Email struct { 6 ID int64 ··· 62 return "", err 63 } 64 return did, nil 65 } 66 67 func GetVerificationCodeForEmail(e Execer, did string, email string) (string, error) {
··· 1 package db 2 3 + import ( 4 + "strings" 5 + "time" 6 + ) 7 8 type Email struct { 9 ID int64 ··· 65 return "", err 66 } 67 return did, nil 68 + } 69 + 70 + func GetDidsForEmails(e Execer, ems []string) ([]string, error) { 71 + if len(ems) == 0 { 72 + return []string{}, nil 73 + } 74 + 75 + // Create placeholders for the IN clause 76 + placeholders := make([]string, len(ems)) 77 + args := make([]interface{}, len(ems)) 78 + for i, em := range ems { 79 + placeholders[i] = "?" 80 + args[i] = em 81 + } 82 + 83 + query := ` 84 + select did 85 + from emails 86 + where email in (` + strings.Join(placeholders, ",") + `) 87 + ` 88 + 89 + rows, err := e.Query(query, args...) 90 + if err != nil { 91 + return nil, err 92 + } 93 + defer rows.Close() 94 + 95 + var dids []string 96 + for rows.Next() { 97 + var did string 98 + if err := rows.Scan(&did); err != nil { 99 + return nil, err 100 + } 101 + dids = append(dids, did) 102 + } 103 + 104 + if err := rows.Err(); err != nil { 105 + return nil, err 106 + } 107 + 108 + return dids, nil 109 } 110 111 func GetVerificationCodeForEmail(e Execer, did string, email string) (string, error) {
+5 -2
appview/pages/pages.go
··· 295 Active string 296 TagMap map[string][]string 297 types.RepoIndexResponse 298 - HTMLReadme template.HTML 299 - Raw bool 300 } 301 302 func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { ··· 328 RepoInfo RepoInfo 329 types.RepoLogResponse 330 Active string 331 } 332 333 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 340 RepoInfo RepoInfo 341 Active string 342 types.RepoCommitResponse 343 } 344 345 func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
··· 295 Active string 296 TagMap map[string][]string 297 types.RepoIndexResponse 298 + HTMLReadme template.HTML 299 + Raw bool 300 + EmailToDidOrHandle map[string]string 301 } 302 303 func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { ··· 329 RepoInfo RepoInfo 330 types.RepoLogResponse 331 Active string 332 + EmailToDidOrHandle map[string]string 333 } 334 335 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 342 RepoInfo RepoInfo 343 Active string 344 types.RepoCommitResponse 345 + EmailToDidOrHandle map[string]string 346 } 347 348 func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
+6 -1
appview/pages/templates/repo/commit.html
··· 20 21 <div class="flex items-center"> 22 <p class="text-sm text-gray-500"> 23 - <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500">{{ $commit.Author.Name }}</a> 24 <span class="px-1 select-none before:content-['\00B7']"></span> 25 {{ timeFmt $commit.Author.When }} 26 <span class="px-1 select-none before:content-['\00B7']"></span>
··· 20 21 <div class="flex items-center"> 22 <p class="text-sm text-gray-500"> 23 + {{ if index .EmailToDidOrHandle $commit.Author.Email }} 24 + {{ $handle := index .EmailToDidOrHandle $commit.Author.Email }} 25 + <a href="/@{{ $handle }}" class="no-underline hover:underline text-gray-500">@{{ $handle }}</a> 26 + {{ else }} 27 + <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500">{{ $commit.Author.Name }}</a> 28 + {{ end }} 29 <span class="px-1 select-none before:content-['\00B7']"></span> 30 {{ timeFmt $commit.Author.When }} 31 <span class="px-1 select-none before:content-['\00B7']"></span>
+3 -2
appview/pages/templates/repo/index.html
··· 164 class="mx-2 before:content-['·'] before:select-none" 165 ></span> 166 <span> 167 <a 168 - href="mailto:{{ .Author.Email }}" 169 class="text-gray-500 no-underline hover:underline" 170 - >{{ .Author.Name }}</a 171 > 172 </span> 173 <div
··· 164 class="mx-2 before:content-['·'] before:select-none" 165 ></span> 166 <span> 167 + {{ $handle := index $.EmailToDidOrHandle .Author.Email }} 168 <a 169 + href="{{ if $handle }}/@{{ $handle }}{{ else }}mailto:{{ .Author.Email }}{{ end }}" 170 class="text-gray-500 no-underline hover:underline" 171 + >{{ if $handle }}@{{ $handle }}{{ else }}{{ .Author.Name }}{{ end }}</a 172 > 173 </span> 174 <div
+18
appview/pages/templates/repo/log.html
··· 25 </span> 26 <span class="mx-2 before:content-['·'] before:select-none"></span> 27 <span> 28 <a 29 href="mailto:{{ $commit.Author.Email }}" 30 class="text-gray-500 no-underline hover:underline" 31 >{{ $commit.Author.Name }}</a 32 > 33 </span> 34 <div 35 class="inline-block px-1 select-none after:content-['·']" ··· 96 class="mx-2 before:content-['·'] before:select-none" 97 ></span> 98 <span> 99 <a 100 href="mailto:{{ .Author.Email }}" 101 class="text-gray-500 no-underline hover:underline" 102 >{{ .Author.Name }}</a 103 > 104 </span> 105 <div 106 class="inline-block px-1 select-none after:content-['·']"
··· 25 </span> 26 <span class="mx-2 before:content-['·'] before:select-none"></span> 27 <span> 28 + {{ $handle := index $.EmailToDidOrHandle $commit.Author.Email }} 29 + {{ if $handle }} 30 + <a 31 + href="/@{{ $handle }}" 32 + class="text-gray-500 no-underline hover:underline" 33 + >@{{ $handle }}</a 34 + > 35 + {{ else }} 36 <a 37 href="mailto:{{ $commit.Author.Email }}" 38 class="text-gray-500 no-underline hover:underline" 39 >{{ $commit.Author.Name }}</a 40 > 41 + {{ end }} 42 </span> 43 <div 44 class="inline-block px-1 select-none after:content-['·']" ··· 105 class="mx-2 before:content-['·'] before:select-none" 106 ></span> 107 <span> 108 + {{ $handle := index $.EmailToDidOrHandle .Author.Email }} 109 + {{ if $handle }} 110 + <a 111 + href="/@{{ $handle }}" 112 + class="text-gray-500 no-underline hover:underline" 113 + >@{{ $handle }}</a 114 + > 115 + {{ else }} 116 <a 117 href="mailto:{{ .Author.Email }}" 118 class="text-gray-500 no-underline hover:underline" 119 >{{ .Author.Name }}</a 120 > 121 + {{ end }} 122 </span> 123 <div 124 class="inline-block px-1 select-none after:content-['·']"
+4 -10
appview/pages/templates/settings.html
··· 31 {{ define "keys" }} 32 <h2 class="text-sm font-bold py-2 px-6 uppercase">ssh keys</h2> 33 <section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 34 <div id="key-list" class="flex flex-col gap-6 mb-8"> 35 {{ range $index, $key := .PubKeys }} 36 <div class="flex justify-between items-center gap-4"> ··· 50 <i class="w-5 h-5" data-lucide="trash-2"></i> 51 </button> 52 </div> 53 - {{ end }} 54 - {{ if .PubKeys }} 55 - <hr class="mb-4" /> 56 {{ end }} 57 </div> 58 - <p class="mb-2">add an ssh key</p> 59 <form 60 hx-put="/settings/keys" 61 hx-swap="none" ··· 76 required 77 class="w-full"/> 78 79 - <button class="btn w-full" type="submit">add key</button> 80 81 <div id="settings-keys" class="error"></div> 82 </form> ··· 86 {{ define "emails" }} 87 <h2 class="text-sm font-bold py-2 px-6 uppercase">email addresses</h2> 88 <section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 89 <div id="email-list" class="flex flex-col gap-6 mb-8"> 90 {{ range $index, $email := .Emails }} 91 <div class="flex justify-between items-center gap-4"> ··· 129 </div> 130 </div> 131 {{ end }} 132 - {{ if .Emails }} 133 - <hr class="mb-4" /> 134 - {{ end }} 135 </div> 136 - <p class="mb-2">add an email address</p> 137 <form 138 hx-put="/settings/emails" 139 hx-swap="none" ··· 147 required 148 class="w-full"/> 149 150 - <button class="btn w-full" type="submit">add email</button> 151 152 <div id="settings-emails-error" class="error"></div> 153 <div id="settings-emails-success" class="success"></div>
··· 31 {{ define "keys" }} 32 <h2 class="text-sm font-bold py-2 px-6 uppercase">ssh keys</h2> 33 <section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 34 + <p class="mb-8">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p> 35 <div id="key-list" class="flex flex-col gap-6 mb-8"> 36 {{ range $index, $key := .PubKeys }} 37 <div class="flex justify-between items-center gap-4"> ··· 51 <i class="w-5 h-5" data-lucide="trash-2"></i> 52 </button> 53 </div> 54 {{ end }} 55 </div> 56 <form 57 hx-put="/settings/keys" 58 hx-swap="none" ··· 73 required 74 class="w-full"/> 75 76 + <button class="btn" type="submit">add key</button> 77 78 <div id="settings-keys" class="error"></div> 79 </form> ··· 83 {{ define "emails" }} 84 <h2 class="text-sm font-bold py-2 px-6 uppercase">email addresses</h2> 85 <section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 86 + <p class="mb-8">Commits authored using emails listed here will be associated with your Tangled profile.</p> 87 <div id="email-list" class="flex flex-col gap-6 mb-8"> 88 {{ range $index, $email := .Emails }} 89 <div class="flex justify-between items-center gap-4"> ··· 127 </div> 128 </div> 129 {{ end }} 130 </div> 131 <form 132 hx-put="/settings/emails" 133 hx-swap="none" ··· 141 required 142 class="w-full"/> 143 144 + <button class="btn" type="submit">add email</button> 145 146 <div id="settings-emails-error" class="error"></div> 147 <div id="settings-emails-success" class="success"></div>
+12 -56
appview/state/repo.go
··· 75 tagMap[hash] = append(tagMap[hash], branch.Name) 76 } 77 78 user := s.auth.GetUser(r) 79 s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 80 - LoggedInUser: user, 81 - RepoInfo: f.RepoInfo(s, user), 82 - TagMap: tagMap, 83 - RepoIndexResponse: result, 84 }) 85 - 86 return 87 } 88 ··· 129 130 user := s.auth.GetUser(r) 131 s.pages.RepoLog(w, pages.RepoLogParams{ 132 - LoggedInUser: user, 133 - RepoInfo: f.RepoInfo(s, user), 134 - RepoLogResponse: repolog, 135 }) 136 return 137 } ··· 265 LoggedInUser: user, 266 RepoInfo: f.RepoInfo(s, user), 267 RepoCommitResponse: result, 268 }) 269 return 270 } ··· 1069 return 1070 } 1071 } 1072 - 1073 - func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { 1074 - repoName := chi.URLParam(r, "repo") 1075 - knot, ok := r.Context().Value("knot").(string) 1076 - if !ok { 1077 - log.Println("malformed middleware") 1078 - return nil, fmt.Errorf("malformed middleware") 1079 - } 1080 - id, ok := r.Context().Value("resolvedId").(identity.Identity) 1081 - if !ok { 1082 - log.Println("malformed middleware") 1083 - return nil, fmt.Errorf("malformed middleware") 1084 - } 1085 - 1086 - repoAt, ok := r.Context().Value("repoAt").(string) 1087 - if !ok { 1088 - log.Println("malformed middleware") 1089 - return nil, fmt.Errorf("malformed middleware") 1090 - } 1091 - 1092 - parsedRepoAt, err := syntax.ParseATURI(repoAt) 1093 - if err != nil { 1094 - log.Println("malformed repo at-uri") 1095 - return nil, fmt.Errorf("malformed middleware") 1096 - } 1097 - 1098 - // pass through values from the middleware 1099 - description, ok := r.Context().Value("repoDescription").(string) 1100 - addedAt, ok := r.Context().Value("repoAddedAt").(string) 1101 - 1102 - return &FullyResolvedRepo{ 1103 - Knot: knot, 1104 - OwnerId: id, 1105 - RepoName: repoName, 1106 - RepoAt: parsedRepoAt, 1107 - Description: description, 1108 - AddedAt: addedAt, 1109 - }, nil 1110 - } 1111 - 1112 - func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo { 1113 - if u != nil { 1114 - r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo()) 1115 - return pages.RolesInRepo{r} 1116 - } else { 1117 - return pages.RolesInRepo{} 1118 - } 1119 - }
··· 75 tagMap[hash] = append(tagMap[hash], branch.Name) 76 } 77 78 + emails := uniqueEmails(result.Commits) 79 + 80 user := s.auth.GetUser(r) 81 s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 82 + LoggedInUser: user, 83 + RepoInfo: f.RepoInfo(s, user), 84 + TagMap: tagMap, 85 + RepoIndexResponse: result, 86 + EmailToDidOrHandle: EmailToDidOrHandle(s, emails), 87 }) 88 return 89 } 90 ··· 131 132 user := s.auth.GetUser(r) 133 s.pages.RepoLog(w, pages.RepoLogParams{ 134 + LoggedInUser: user, 135 + RepoInfo: f.RepoInfo(s, user), 136 + RepoLogResponse: repolog, 137 + EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)), 138 }) 139 return 140 } ··· 268 LoggedInUser: user, 269 RepoInfo: f.RepoInfo(s, user), 270 RepoCommitResponse: result, 271 + EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}), 272 }) 273 return 274 } ··· 1073 return 1074 } 1075 }
+113
appview/state/repo_util.go
···
··· 1 + package state 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + 9 + "github.com/bluesky-social/indigo/atproto/identity" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/go-chi/chi/v5" 12 + "github.com/go-git/go-git/v5/plumbing/object" 13 + "github.com/sotangled/tangled/appview/auth" 14 + "github.com/sotangled/tangled/appview/db" 15 + "github.com/sotangled/tangled/appview/pages" 16 + ) 17 + 18 + func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { 19 + repoName := chi.URLParam(r, "repo") 20 + knot, ok := r.Context().Value("knot").(string) 21 + if !ok { 22 + log.Println("malformed middleware") 23 + return nil, fmt.Errorf("malformed middleware") 24 + } 25 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 26 + if !ok { 27 + log.Println("malformed middleware") 28 + return nil, fmt.Errorf("malformed middleware") 29 + } 30 + 31 + repoAt, ok := r.Context().Value("repoAt").(string) 32 + if !ok { 33 + log.Println("malformed middleware") 34 + return nil, fmt.Errorf("malformed middleware") 35 + } 36 + 37 + parsedRepoAt, err := syntax.ParseATURI(repoAt) 38 + if err != nil { 39 + log.Println("malformed repo at-uri") 40 + return nil, fmt.Errorf("malformed middleware") 41 + } 42 + 43 + // pass through values from the middleware 44 + description, ok := r.Context().Value("repoDescription").(string) 45 + addedAt, ok := r.Context().Value("repoAddedAt").(string) 46 + 47 + return &FullyResolvedRepo{ 48 + Knot: knot, 49 + OwnerId: id, 50 + RepoName: repoName, 51 + RepoAt: parsedRepoAt, 52 + Description: description, 53 + AddedAt: addedAt, 54 + }, nil 55 + } 56 + 57 + func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo { 58 + if u != nil { 59 + r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo()) 60 + return pages.RolesInRepo{r} 61 + } else { 62 + return pages.RolesInRepo{} 63 + } 64 + } 65 + 66 + func uniqueEmails(commits []*object.Commit) []string { 67 + emails := make(map[string]struct{}) 68 + for _, commit := range commits { 69 + if commit.Author.Email != "" { 70 + emails[commit.Author.Email] = struct{}{} 71 + } 72 + if commit.Committer.Email != "" { 73 + emails[commit.Committer.Email] = struct{}{} 74 + } 75 + } 76 + var uniqueEmails []string 77 + for email := range emails { 78 + uniqueEmails = append(uniqueEmails, email) 79 + } 80 + return uniqueEmails 81 + } 82 + 83 + func EmailToDidOrHandle(s *State, emails []string) map[string]string { 84 + dids, err := db.GetDidsForEmails(s.db, emails) 85 + if err != nil { 86 + log.Printf("error fetching dids for emails: %v", err) 87 + return nil 88 + } 89 + 90 + didHandleMap := make(map[string]string) 91 + emailToDid := make(map[string]string) 92 + resolvedIdents := s.resolver.ResolveIdents(context.Background(), dids) 93 + for i, resolved := range resolvedIdents { 94 + if resolved != nil { 95 + didHandleMap[dids[i]] = resolved.Handle.String() 96 + if i < len(emails) { 97 + emailToDid[emails[i]] = dids[i] 98 + } 99 + } 100 + } 101 + 102 + // Create map of email to didOrHandle for commit display 103 + emailToDidOrHandle := make(map[string]string) 104 + for email, did := range emailToDid { 105 + if handle, ok := didHandleMap[did]; ok { 106 + emailToDidOrHandle[email] = handle 107 + } else { 108 + emailToDidOrHandle[email] = did 109 + } 110 + } 111 + 112 + return emailToDidOrHandle 113 + }
+3 -45
appview/state/state.go
··· 5 "crypto/hmac" 6 "crypto/sha256" 7 "encoding/hex" 8 - "encoding/json" 9 "fmt" 10 "log" 11 "log/slog" ··· 764 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 765 } 766 767 - profileAvatarUri, err := GetAvatarUri(ident.DID.String(), ident.PDSEndpoint()) 768 if err != nil { 769 log.Println("failed to fetch bsky avatar", err) 770 } ··· 785 }) 786 } 787 788 - func GetAvatarUri(did string, pds string) (string, error) { 789 - recordURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=app.bsky.actor.profile&rkey=self", pds, did) 790 - 791 - recordResp, err := http.Get(recordURL) 792 - if err != nil { 793 - return "", err 794 - } 795 - defer recordResp.Body.Close() 796 - 797 - if recordResp.StatusCode != http.StatusOK { 798 - return "", fmt.Errorf("getRecord API returned status code %d", recordResp.StatusCode) 799 - } 800 - 801 - var profileResp map[string]any 802 - if err := json.NewDecoder(recordResp.Body).Decode(&profileResp); err != nil { 803 - return "", err 804 - } 805 - 806 - value, ok := profileResp["value"].(map[string]any) 807 - if !ok { 808 - log.Println(profileResp) 809 - return "", fmt.Errorf("no value found for handle %s", did) 810 - } 811 - 812 - avatar, ok := value["avatar"].(map[string]any) 813 - if !ok { 814 - log.Println(profileResp) 815 - return "", fmt.Errorf("no avatar found for handle %s", did) 816 - } 817 - 818 - blobRef, ok := avatar["ref"].(map[string]any) 819 - if !ok { 820 - log.Println(profileResp) 821 - return "", fmt.Errorf("no ref found for handle %s", did) 822 - } 823 - 824 - link, ok := blobRef["$link"].(string) 825 - if !ok { 826 - log.Println(profileResp) 827 - return "", fmt.Errorf("no link found for handle %s", did) 828 - } 829 - 830 - return fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", pds, did, link), nil 831 }
··· 5 "crypto/hmac" 6 "crypto/sha256" 7 "encoding/hex" 8 "fmt" 9 "log" 10 "log/slog" ··· 763 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 764 } 765 766 + profileAvatarUri, err := GetAvatarUri(ident.Handle.String()) 767 if err != nil { 768 log.Println("failed to fetch bsky avatar", err) 769 } ··· 784 }) 785 } 786 787 + func GetAvatarUri(handle string) (string, error) { 788 + return fmt.Sprintf("https://avatars.dog/%s@webp", handle), nil 789 }