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 1 package db 2 2 3 - import "time" 3 + import ( 4 + "strings" 5 + "time" 6 + ) 4 7 5 8 type Email struct { 6 9 ID int64 ··· 62 65 return "", err 63 66 } 64 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 65 109 } 66 110 67 111 func GetVerificationCodeForEmail(e Execer, did string, email string) (string, error) {
+5 -2
appview/pages/pages.go
··· 295 295 Active string 296 296 TagMap map[string][]string 297 297 types.RepoIndexResponse 298 - HTMLReadme template.HTML 299 - Raw bool 298 + HTMLReadme template.HTML 299 + Raw bool 300 + EmailToDidOrHandle map[string]string 300 301 } 301 302 302 303 func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { ··· 328 329 RepoInfo RepoInfo 329 330 types.RepoLogResponse 330 331 Active string 332 + EmailToDidOrHandle map[string]string 331 333 } 332 334 333 335 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 340 342 RepoInfo RepoInfo 341 343 Active string 342 344 types.RepoCommitResponse 345 + EmailToDidOrHandle map[string]string 343 346 } 344 347 345 348 func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
+6 -1
appview/pages/templates/repo/commit.html
··· 20 20 21 21 <div class="flex items-center"> 22 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> 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 }} 24 29 <span class="px-1 select-none before:content-['\00B7']"></span> 25 30 {{ timeFmt $commit.Author.When }} 26 31 <span class="px-1 select-none before:content-['\00B7']"></span>
+3 -2
appview/pages/templates/repo/index.html
··· 164 164 class="mx-2 before:content-['·'] before:select-none" 165 165 ></span> 166 166 <span> 167 + {{ $handle := index $.EmailToDidOrHandle .Author.Email }} 167 168 <a 168 - href="mailto:{{ .Author.Email }}" 169 + href="{{ if $handle }}/@{{ $handle }}{{ else }}mailto:{{ .Author.Email }}{{ end }}" 169 170 class="text-gray-500 no-underline hover:underline" 170 - >{{ .Author.Name }}</a 171 + >{{ if $handle }}@{{ $handle }}{{ else }}{{ .Author.Name }}{{ end }}</a 171 172 > 172 173 </span> 173 174 <div
+18
appview/pages/templates/repo/log.html
··· 25 25 </span> 26 26 <span class="mx-2 before:content-['·'] before:select-none"></span> 27 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 }} 28 36 <a 29 37 href="mailto:{{ $commit.Author.Email }}" 30 38 class="text-gray-500 no-underline hover:underline" 31 39 >{{ $commit.Author.Name }}</a 32 40 > 41 + {{ end }} 33 42 </span> 34 43 <div 35 44 class="inline-block px-1 select-none after:content-['·']" ··· 96 105 class="mx-2 before:content-['·'] before:select-none" 97 106 ></span> 98 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 }} 99 116 <a 100 117 href="mailto:{{ .Author.Email }}" 101 118 class="text-gray-500 no-underline hover:underline" 102 119 >{{ .Author.Name }}</a 103 120 > 121 + {{ end }} 104 122 </span> 105 123 <div 106 124 class="inline-block px-1 select-none after:content-['·']"
+4 -10
appview/pages/templates/settings.html
··· 31 31 {{ define "keys" }} 32 32 <h2 class="text-sm font-bold py-2 px-6 uppercase">ssh keys</h2> 33 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> 34 35 <div id="key-list" class="flex flex-col gap-6 mb-8"> 35 36 {{ range $index, $key := .PubKeys }} 36 37 <div class="flex justify-between items-center gap-4"> ··· 50 51 <i class="w-5 h-5" data-lucide="trash-2"></i> 51 52 </button> 52 53 </div> 53 - {{ end }} 54 - {{ if .PubKeys }} 55 - <hr class="mb-4" /> 56 54 {{ end }} 57 55 </div> 58 - <p class="mb-2">add an ssh key</p> 59 56 <form 60 57 hx-put="/settings/keys" 61 58 hx-swap="none" ··· 76 73 required 77 74 class="w-full"/> 78 75 79 - <button class="btn w-full" type="submit">add key</button> 76 + <button class="btn" type="submit">add key</button> 80 77 81 78 <div id="settings-keys" class="error"></div> 82 79 </form> ··· 86 83 {{ define "emails" }} 87 84 <h2 class="text-sm font-bold py-2 px-6 uppercase">email addresses</h2> 88 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> 89 87 <div id="email-list" class="flex flex-col gap-6 mb-8"> 90 88 {{ range $index, $email := .Emails }} 91 89 <div class="flex justify-between items-center gap-4"> ··· 129 127 </div> 130 128 </div> 131 129 {{ end }} 132 - {{ if .Emails }} 133 - <hr class="mb-4" /> 134 - {{ end }} 135 130 </div> 136 - <p class="mb-2">add an email address</p> 137 131 <form 138 132 hx-put="/settings/emails" 139 133 hx-swap="none" ··· 147 141 required 148 142 class="w-full"/> 149 143 150 - <button class="btn w-full" type="submit">add email</button> 144 + <button class="btn" type="submit">add email</button> 151 145 152 146 <div id="settings-emails-error" class="error"></div> 153 147 <div id="settings-emails-success" class="success"></div>
+12 -56
appview/state/repo.go
··· 75 75 tagMap[hash] = append(tagMap[hash], branch.Name) 76 76 } 77 77 78 + emails := uniqueEmails(result.Commits) 79 + 78 80 user := s.auth.GetUser(r) 79 81 s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 80 - LoggedInUser: user, 81 - RepoInfo: f.RepoInfo(s, user), 82 - TagMap: tagMap, 83 - RepoIndexResponse: result, 82 + LoggedInUser: user, 83 + RepoInfo: f.RepoInfo(s, user), 84 + TagMap: tagMap, 85 + RepoIndexResponse: result, 86 + EmailToDidOrHandle: EmailToDidOrHandle(s, emails), 84 87 }) 85 - 86 88 return 87 89 } 88 90 ··· 129 131 130 132 user := s.auth.GetUser(r) 131 133 s.pages.RepoLog(w, pages.RepoLogParams{ 132 - LoggedInUser: user, 133 - RepoInfo: f.RepoInfo(s, user), 134 - RepoLogResponse: repolog, 134 + LoggedInUser: user, 135 + RepoInfo: f.RepoInfo(s, user), 136 + RepoLogResponse: repolog, 137 + EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)), 135 138 }) 136 139 return 137 140 } ··· 265 268 LoggedInUser: user, 266 269 RepoInfo: f.RepoInfo(s, user), 267 270 RepoCommitResponse: result, 271 + EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}), 268 272 }) 269 273 return 270 274 } ··· 1069 1073 return 1070 1074 } 1071 1075 } 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 - }
+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 5 "crypto/hmac" 6 6 "crypto/sha256" 7 7 "encoding/hex" 8 - "encoding/json" 9 8 "fmt" 10 9 "log" 11 10 "log/slog" ··· 764 763 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 765 764 } 766 765 767 - profileAvatarUri, err := GetAvatarUri(ident.DID.String(), ident.PDSEndpoint()) 766 + profileAvatarUri, err := GetAvatarUri(ident.Handle.String()) 768 767 if err != nil { 769 768 log.Println("failed to fetch bsky avatar", err) 770 769 } ··· 785 784 }) 786 785 } 787 786 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 787 + func GetAvatarUri(handle string) (string, error) { 788 + return fmt.Sprintf("https://avatars.dog/%s@webp", handle), nil 831 789 }