+45
-1
appview/db/email.go
+45
-1
appview/db/email.go
···
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
+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
+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
+3
-2
appview/pages/templates/repo/index.html
···
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
+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
+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
+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
+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
"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
}