+45
-1
appview/db/email.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
}