+35
-20
appview/db/issues.go
+35
-20
appview/db/issues.go
···
5
5
"time"
6
6
7
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"tangled.sh/tangled.sh/core/appview/pagination"
8
9
)
9
10
10
11
type Issue struct {
···
102
103
return ownerDid, err
103
104
}
104
105
105
-
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool) ([]Issue, error) {
106
+
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
106
107
var issues []Issue
107
108
openValue := 0
108
109
if isOpen {
···
110
111
}
111
112
112
113
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)
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)
132
147
if err != nil {
133
148
return nil, err
134
149
}
+62
appview/filetree/filetree.go
+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
+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
+2
appview/pages/funcmap.go
···
13
13
"time"
14
14
15
15
"github.com/dustin/go-humanize"
16
+
"tangled.sh/tangled.sh/core/appview/filetree"
16
17
"tangled.sh/tangled.sh/core/appview/pages/markup"
17
18
)
18
19
···
174
175
return template.HTML(data)
175
176
},
176
177
"cssContentHash": CssContentHash,
178
+
"fileTree": filetree.FileTree,
177
179
}
178
180
}
179
181
+129
-37
appview/pages/pages.go
+129
-37
appview/pages/pages.go
···
11
11
"io/fs"
12
12
"log"
13
13
"net/http"
14
+
"os"
14
15
"path"
15
16
"path/filepath"
16
17
"slices"
···
19
20
"tangled.sh/tangled.sh/core/appview/auth"
20
21
"tangled.sh/tangled.sh/core/appview/db"
21
22
"tangled.sh/tangled.sh/core/appview/pages/markup"
23
+
"tangled.sh/tangled.sh/core/appview/pagination"
22
24
"tangled.sh/tangled.sh/core/appview/state/userutil"
23
25
"tangled.sh/tangled.sh/core/patchutil"
24
26
"tangled.sh/tangled.sh/core/types"
···
35
37
var Files embed.FS
36
38
37
39
type Pages struct {
38
-
t map[string]*template.Template
40
+
t map[string]*template.Template
41
+
dev bool
42
+
embedFS embed.FS
43
+
templateDir string // Path to templates on disk for dev mode
39
44
}
40
45
41
-
func NewPages() *Pages {
42
-
templates := make(map[string]*template.Template)
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
+
}
43
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)
44
62
var fragmentPaths []string
63
+
64
+
// Use embedded FS for initial loading
45
65
// First, collect all fragment paths
46
-
err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
66
+
err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
47
67
if err != nil {
48
68
return err
49
69
}
50
-
51
70
if d.IsDir() {
52
71
return nil
53
72
}
54
-
55
73
if !strings.HasSuffix(path, ".html") {
56
74
return nil
57
75
}
58
-
59
76
if !strings.Contains(path, "fragments/") {
60
77
return nil
61
78
}
62
-
63
79
name := strings.TrimPrefix(path, "templates/")
64
80
name = strings.TrimSuffix(name, ".html")
65
-
66
81
tmpl, err := template.New(name).
67
82
Funcs(funcMap()).
68
-
ParseFS(Files, path)
83
+
ParseFS(p.embedFS, path)
69
84
if err != nil {
70
85
log.Fatalf("setting up fragment: %v", err)
71
86
}
72
-
73
87
templates[name] = tmpl
74
88
fragmentPaths = append(fragmentPaths, path)
75
89
log.Printf("loaded fragment: %s", name)
···
80
94
}
81
95
82
96
// 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 {
97
+
err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
84
98
if err != nil {
85
99
return err
86
100
}
87
-
88
101
if d.IsDir() {
89
102
return nil
90
103
}
91
-
92
104
if !strings.HasSuffix(path, "html") {
93
105
return nil
94
106
}
95
-
96
107
// Skip fragments as they've already been loaded
97
108
if strings.Contains(path, "fragments/") {
98
109
return nil
99
110
}
100
-
101
111
// Skip layouts
102
112
if strings.Contains(path, "layouts/") {
103
113
return nil
104
114
}
105
-
106
115
name := strings.TrimPrefix(path, "templates/")
107
116
name = strings.TrimSuffix(name, ".html")
108
-
109
117
// Add the page template on top of the base
110
118
allPaths := []string{}
111
119
allPaths = append(allPaths, "templates/layouts/*.html")
···
113
121
allPaths = append(allPaths, path)
114
122
tmpl, err := template.New(name).
115
123
Funcs(funcMap()).
116
-
ParseFS(Files, allPaths...)
124
+
ParseFS(p.embedFS, allPaths...)
117
125
if err != nil {
118
126
return fmt.Errorf("setting up template: %w", err)
119
127
}
120
-
121
128
templates[name] = tmpl
122
129
log.Printf("loaded template: %s", name)
123
130
return nil
···
127
134
}
128
135
129
136
log.Printf("total templates loaded: %d", len(templates))
137
+
p.t = templates
138
+
}
130
139
131
-
return &Pages{
132
-
t: templates,
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)
133
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
134
200
}
135
201
136
-
type LoginParams struct {
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
+
}
137
221
}
138
222
139
223
func (p *Pages) execute(name string, w io.Writer, params any) error {
140
-
return p.t[name].ExecuteTemplate(w, "layouts/base", params)
224
+
return p.executeOrReload(name, w, "layouts/base", params)
141
225
}
142
226
143
227
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
144
-
return p.t[name].Execute(w, params)
228
+
return p.executeOrReload(name, w, "", params)
145
229
}
146
230
147
231
func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
148
-
return p.t[name].ExecuteTemplate(w, "layouts/repobase", params)
232
+
return p.executeOrReload(name, w, "layouts/repobase", params)
233
+
}
234
+
235
+
type LoginParams struct {
149
236
}
150
237
151
238
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
419
506
}
420
507
421
508
type RepoCommitParams struct {
422
-
LoggedInUser *auth.User
423
-
RepoInfo RepoInfo
424
-
Active string
509
+
LoggedInUser *auth.User
510
+
RepoInfo RepoInfo
511
+
Active string
512
+
EmailToDidOrHandle map[string]string
513
+
425
514
types.RepoCommitResponse
426
-
EmailToDidOrHandle map[string]string
427
515
}
428
516
429
517
func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
···
564
652
}
565
653
566
654
type RepoIssuesParams struct {
567
-
LoggedInUser *auth.User
568
-
RepoInfo RepoInfo
569
-
Active string
570
-
Issues []db.Issue
571
-
DidHandleMap map[string]string
572
-
655
+
LoggedInUser *auth.User
656
+
RepoInfo RepoInfo
657
+
Active string
658
+
Issues []db.Issue
659
+
DidHandleMap map[string]string
660
+
Page pagination.Page
573
661
FilteringByOpen bool
574
662
}
575
663
···
698
786
DidHandleMap map[string]string
699
787
RepoInfo RepoInfo
700
788
Pull *db.Pull
701
-
Diff types.NiceDiff
789
+
Diff *types.NiceDiff
702
790
Round int
703
791
Submission *db.PullSubmission
704
792
}
···
794
882
}
795
883
796
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
+
797
889
sub, err := fs.Sub(Files, "static")
798
890
if err != nil {
799
891
log.Fatalf("no static dir found? that's crazy: %v", err)
+4
-17
appview/pages/templates/repo/fragments/diff.html
+4
-17
appview/pages/templates/repo/fragments/diff.html
···
3
3
{{ $diff := index . 1 }}
4
4
{{ $commit := $diff.Commit }}
5
5
{{ $stat := $diff.Stat }}
6
+
{{ $fileTree := fileTree $diff.ChangedFiles }}
6
7
{{ $diff := $diff.Diff }}
7
8
8
9
{{ $this := $commit.This }}
···
14
15
<strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
15
16
{{ block "statPill" $stat }} {{ end }}
16
17
</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>
18
+
{{ block "fileTree" $fileTree }} {{ end }}
28
19
</div>
29
20
</section>
30
21
···
38
29
<summary class="list-none cursor-pointer sticky top-0">
39
30
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
40
31
<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;">
32
+
<div class="flex gap-1 items-center">
42
33
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
43
34
{{ if .IsNew }}
44
35
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span>
···
55
46
{{ block "statPill" .Stats }} {{ end }}
56
47
</div>
57
48
58
-
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
49
+
<div class="flex gap-2 items-center overflow-x-auto">
59
50
{{ if .IsDelete }}
60
51
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
61
52
{{ .Name.Old }}
···
101
92
{{ else if .IsCopy }}
102
93
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
103
94
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
95
</p>
109
96
{{ else if .IsBinary }}
110
97
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+27
appview/pages/templates/repo/fragments/filetree.html
+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
+2
-7
appview/pages/templates/repo/fragments/interdiff.html
···
1
1
{{ define "repo/fragments/interdiff" }}
2
2
{{ $repo := index . 0 }}
3
3
{{ $x := index . 1 }}
4
+
{{ $fileTree := fileTree $x.AffectedFiles }}
4
5
{{ $diff := $x.Files }}
5
6
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">
···
8
9
<div class="flex gap-2 items-center">
9
10
<strong class="text-sm uppercase dark:text-gray-200">files</strong>
10
11
</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>
12
+
{{ block "fileTree" $fileTree }} {{ end }}
18
13
</div>
19
14
</section>
20
15
+38
appview/pages/templates/repo/issues/issues.html
+38
appview/pages/templates/repo/issues/issues.html
···
70
70
</div>
71
71
{{ end }}
72
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>
73
111
{{ end }}
+1
-1
appview/pages/templates/user/profile.html
+1
-1
appview/pages/templates/user/profile.html
···
179
179
180
180
181
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 ">
182
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
183
183
{{$stats.Closed}} closed
184
184
</span>
185
185
{{ end }}
+31
appview/pagination/page.go
+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
+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
+2
-1
appview/state/follow.go
···
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
10
tangled "tangled.sh/tangled.sh/core/api/tangled"
11
+
"tangled.sh/tangled.sh/core/appview"
11
12
"tangled.sh/tangled.sh/core/appview/db"
12
13
"tangled.sh/tangled.sh/core/appview/pages"
13
14
)
···
36
37
switch r.Method {
37
38
case http.MethodPost:
38
39
createdAt := time.Now().Format(time.RFC3339)
39
-
rkey := s.TID()
40
+
rkey := appview.TID()
40
41
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
41
42
Collection: tangled.GraphFollowNSID,
42
43
Repo: currentUser.Did,
+7
-92
appview/state/middleware.go
+7
-92
appview/state/middleware.go
···
10
10
11
11
"slices"
12
12
13
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
13
"github.com/bluesky-social/indigo/atproto/identity"
15
-
"github.com/bluesky-social/indigo/xrpc"
16
14
"github.com/go-chi/chi/v5"
17
-
"tangled.sh/tangled.sh/core/appview"
18
-
"tangled.sh/tangled.sh/core/appview/auth"
19
15
"tangled.sh/tangled.sh/core/appview/db"
16
+
"tangled.sh/tangled.sh/core/appview/middleware"
20
17
)
21
18
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 {
19
+
func knotRoleMiddleware(s *State, group string) middleware.Middleware {
105
20
return func(next http.Handler) http.Handler {
106
21
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107
22
// requires auth also
···
131
46
}
132
47
}
133
48
134
-
func KnotOwner(s *State) Middleware {
49
+
func KnotOwner(s *State) middleware.Middleware {
135
50
return knotRoleMiddleware(s, "server:owner")
136
51
}
137
52
138
-
func RepoPermissionMiddleware(s *State, requiredPerm string) Middleware {
53
+
func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middleware {
139
54
return func(next http.Handler) http.Handler {
140
55
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
141
56
// requires auth also
···
175
90
})
176
91
}
177
92
178
-
func ResolveIdent(s *State) Middleware {
93
+
func ResolveIdent(s *State) middleware.Middleware {
179
94
excluded := []string{"favicon.ico"}
180
95
181
96
return func(next http.Handler) http.Handler {
···
201
116
}
202
117
}
203
118
204
-
func ResolveRepo(s *State) Middleware {
119
+
func ResolveRepo(s *State) middleware.Middleware {
205
120
return func(next http.Handler) http.Handler {
206
121
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
207
122
repoName := chi.URLParam(req, "repo")
···
230
145
}
231
146
232
147
// middleware that is tacked on top of /{user}/{repo}/pulls/{pull}
233
-
func ResolvePull(s *State) Middleware {
148
+
func ResolvePull(s *State) middleware.Middleware {
234
149
return func(next http.Handler) http.Handler {
235
150
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
236
151
f, err := fullyResolvedRepo(r)
+6
-3
appview/state/pull.go
+6
-3
appview/state/pull.go
···
13
13
"time"
14
14
15
15
"tangled.sh/tangled.sh/core/api/tangled"
16
+
"tangled.sh/tangled.sh/core/appview"
16
17
"tangled.sh/tangled.sh/core/appview/auth"
17
18
"tangled.sh/tangled.sh/core/appview/db"
18
19
"tangled.sh/tangled.sh/core/appview/pages"
···
283
284
}
284
285
}
285
286
287
+
diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch)
288
+
286
289
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
287
290
LoggedInUser: user,
288
291
DidHandleMap: didHandleMap,
···
290
293
Pull: pull,
291
294
Round: roundIdInt,
292
295
Submission: pull.Submissions[roundIdInt],
293
-
Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch),
296
+
Diff: &diff,
294
297
})
295
298
296
299
}
···
521
524
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
522
525
Collection: tangled.RepoPullCommentNSID,
523
526
Repo: user.Did,
524
-
Rkey: s.TID(),
527
+
Rkey: appview.TID(),
525
528
Record: &lexutil.LexiconTypeDecoder{
526
529
Val: &tangled.RepoPullComment{
527
530
Repo: &atUri,
···
846
849
body = formatPatches[0].Body
847
850
}
848
851
849
-
rkey := s.TID()
852
+
rkey := appview.TID()
850
853
initialSubmission := db.PullSubmission{
851
854
Patch: patch,
852
855
SourceRev: sourceRev,
+14
-5
appview/state/repo.go
+14
-5
appview/state/repo.go
···
23
23
"github.com/go-chi/chi/v5"
24
24
"github.com/go-git/go-git/v5/plumbing"
25
25
"tangled.sh/tangled.sh/core/api/tangled"
26
+
"tangled.sh/tangled.sh/core/appview"
26
27
"tangled.sh/tangled.sh/core/appview/auth"
27
28
"tangled.sh/tangled.sh/core/appview/db"
28
29
"tangled.sh/tangled.sh/core/appview/pages"
29
30
"tangled.sh/tangled.sh/core/appview/pages/markup"
31
+
"tangled.sh/tangled.sh/core/appview/pagination"
30
32
"tangled.sh/tangled.sh/core/types"
31
33
32
34
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
1116
1118
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1117
1119
Collection: tangled.RepoIssueStateNSID,
1118
1120
Repo: user.Did,
1119
-
Rkey: s.TID(),
1121
+
Rkey: appview.TID(),
1120
1122
Record: &lexutil.LexiconTypeDecoder{
1121
1123
Val: &tangled.RepoIssueState{
1122
1124
Issue: issue.IssueAt,
···
1220
1222
}
1221
1223
1222
1224
commentId := mathrand.IntN(1000000)
1223
-
rkey := s.TID()
1225
+
rkey := appview.TID()
1224
1226
1225
1227
err := db.NewIssueComment(s.db, &db.Comment{
1226
1228
OwnerDid: user.Did,
···
1558
1560
isOpen = true
1559
1561
}
1560
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
+
1561
1569
user := s.auth.GetUser(r)
1562
1570
f, err := fullyResolvedRepo(r)
1563
1571
if err != nil {
···
1565
1573
return
1566
1574
}
1567
1575
1568
-
issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
1576
+
issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page)
1569
1577
if err != nil {
1570
1578
log.Println("failed to get issues", err)
1571
1579
s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
1592
1600
Issues: issues,
1593
1601
DidHandleMap: didHandleMap,
1594
1602
FilteringByOpen: isOpen,
1603
+
Page: page,
1595
1604
})
1596
1605
return
1597
1606
}
···
1650
1659
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1651
1660
Collection: tangled.RepoIssueNSID,
1652
1661
Repo: user.Did,
1653
-
Rkey: s.TID(),
1662
+
Rkey: appview.TID(),
1654
1663
Record: &lexutil.LexiconTypeDecoder{
1655
1664
Val: &tangled.RepoIssue{
1656
1665
Repo: atUri,
···
1754
1763
sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1755
1764
sourceAt := f.RepoAt.String()
1756
1765
1757
-
rkey := s.TID()
1766
+
rkey := appview.TID()
1758
1767
repo := &db.Repo{
1759
1768
Did: user.Did,
1760
1769
Name: forkName,
+26
-23
appview/state/router.go
+26
-23
appview/state/router.go
···
5
5
"strings"
6
6
7
7
"github.com/go-chi/chi/v5"
8
+
"tangled.sh/tangled.sh/core/appview/middleware"
9
+
"tangled.sh/tangled.sh/core/appview/settings"
8
10
"tangled.sh/tangled.sh/core/appview/state/userutil"
9
11
)
10
12
···
66
68
r.Get("/blob/{ref}/raw/*", s.RepoBlobRaw)
67
69
68
70
r.Route("/issues", func(r chi.Router) {
69
-
r.Get("/", s.RepoIssues)
71
+
r.With(middleware.Paginate).Get("/", s.RepoIssues)
70
72
r.Get("/{issue}", s.RepoSingleIssue)
71
73
72
74
r.Group(func(r chi.Router) {
73
-
r.Use(AuthMiddleware(s))
75
+
r.Use(middleware.AuthMiddleware(s.auth))
74
76
r.Get("/new", s.NewIssue)
75
77
r.Post("/new", s.NewIssue)
76
78
r.Post("/{issue}/comment", s.NewIssueComment)
···
86
88
})
87
89
88
90
r.Route("/fork", func(r chi.Router) {
89
-
r.Use(AuthMiddleware(s))
91
+
r.Use(middleware.AuthMiddleware(s.auth))
90
92
r.Get("/", s.ForkRepo)
91
93
r.Post("/", s.ForkRepo)
92
94
})
93
95
94
96
r.Route("/pulls", func(r chi.Router) {
95
97
r.Get("/", s.RepoPulls)
96
-
r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) {
98
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/new", func(r chi.Router) {
97
99
r.Get("/", s.NewPull)
98
100
r.Get("/patch-upload", s.PatchUploadFragment)
99
101
r.Post("/validate-patch", s.ValidatePatch)
···
111
113
r.Get("/", s.RepoPullPatch)
112
114
r.Get("/interdiff", s.RepoPullInterdiff)
113
115
r.Get("/actions", s.PullActions)
114
-
r.With(AuthMiddleware(s)).Route("/comment", func(r chi.Router) {
116
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/comment", func(r chi.Router) {
115
117
r.Get("/", s.PullComment)
116
118
r.Post("/", s.PullComment)
117
119
})
···
122
124
})
123
125
124
126
r.Group(func(r chi.Router) {
125
-
r.Use(AuthMiddleware(s))
127
+
r.Use(middleware.AuthMiddleware(s.auth))
126
128
r.Route("/resubmit", func(r chi.Router) {
127
129
r.Get("/", s.ResubmitPull)
128
130
r.Post("/", s.ResubmitPull)
···
145
147
146
148
// settings routes, needs auth
147
149
r.Group(func(r chi.Router) {
148
-
r.Use(AuthMiddleware(s))
150
+
r.Use(middleware.AuthMiddleware(s.auth))
149
151
// repo description can only be edited by owner
150
152
r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) {
151
153
r.Put("/", s.RepoDescription)
···
176
178
177
179
r.Get("/", s.Timeline)
178
180
179
-
r.With(AuthMiddleware(s)).Post("/logout", s.Logout)
181
+
r.With(middleware.AuthMiddleware(s.auth)).Post("/logout", s.Logout)
180
182
181
183
r.Route("/login", func(r chi.Router) {
182
184
r.Get("/", s.Login)
···
184
186
})
185
187
186
188
r.Route("/knots", func(r chi.Router) {
187
-
r.Use(AuthMiddleware(s))
189
+
r.Use(middleware.AuthMiddleware(s.auth))
188
190
r.Get("/", s.Knots)
189
191
r.Post("/key", s.RegistrationKey)
190
192
···
202
204
203
205
r.Route("/repo", func(r chi.Router) {
204
206
r.Route("/new", func(r chi.Router) {
205
-
r.Use(AuthMiddleware(s))
207
+
r.Use(middleware.AuthMiddleware(s.auth))
206
208
r.Get("/", s.NewRepo)
207
209
r.Post("/", s.NewRepo)
208
210
})
209
211
// r.Post("/import", s.ImportRepo)
210
212
})
211
213
212
-
r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) {
214
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/follow", func(r chi.Router) {
213
215
r.Post("/", s.Follow)
214
216
r.Delete("/", s.Follow)
215
217
})
216
218
217
-
r.With(AuthMiddleware(s)).Route("/star", func(r chi.Router) {
219
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/star", func(r chi.Router) {
218
220
r.Post("/", s.Star)
219
221
r.Delete("/", s.Star)
220
222
})
221
223
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
-
})
224
+
r.Mount("/settings", s.SettingsRouter())
233
225
234
226
r.Get("/keys/{user}", s.Keys)
235
227
···
238
230
})
239
231
return r
240
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
-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
+2
-1
appview/state/star.go
···
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
11
tangled "tangled.sh/tangled.sh/core/api/tangled"
12
+
"tangled.sh/tangled.sh/core/appview"
12
13
"tangled.sh/tangled.sh/core/appview/db"
13
14
"tangled.sh/tangled.sh/core/appview/pages"
14
15
)
···
33
34
switch r.Method {
34
35
case http.MethodPost:
35
36
createdAt := time.Now().Format(time.RFC3339)
36
-
rkey := s.TID()
37
+
rkey := appview.TID()
37
38
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
38
39
Collection: tangled.FeedStarNSID,
39
40
Repo: currentUser.Did,
+5
-5
appview/state/state.go
+5
-5
appview/state/state.go
···
55
55
56
56
clock := syntax.NewTIDClock(0)
57
57
58
-
pgs := pages.NewPages()
58
+
pgs := pages.NewPages(config.Dev)
59
59
60
60
resolver := appview.NewResolver()
61
61
···
91
91
return state, nil
92
92
}
93
93
94
-
func (s *State) TID() string {
95
-
return s.tidClock.Next().String()
94
+
func TID(c *syntax.TIDClock) string {
95
+
return c.Next().String()
96
96
}
97
97
98
98
func (s *State) Login(w http.ResponseWriter, r *http.Request) {
···
522
522
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
523
523
Collection: tangled.KnotMemberNSID,
524
524
Repo: currentUser.Did,
525
-
Rkey: s.TID(),
525
+
Rkey: appview.TID(),
526
526
Record: &lexutil.LexiconTypeDecoder{
527
527
Val: &tangled.KnotMember{
528
528
Member: memberIdent.DID.String(),
···
646
646
return
647
647
}
648
648
649
-
rkey := s.TID()
649
+
rkey := appview.TID()
650
650
repo := &db.Repo{
651
651
Did: user.Did,
652
652
Name: repoName,
+11
appview/tid.go
+11
appview/tid.go
+9
-7
docs/contributing.md
+9
-7
docs/contributing.md
···
11
11
### message format
12
12
13
13
```
14
-
<service/top-level directory>: <package/path>: <short summary of change>
14
+
<service/top-level directory>: <affected package/directory>: <short summary of change>
15
15
16
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.
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
21
```
22
22
23
23
Here are some examples:
···
35
35
36
36
### general notes
37
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.
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.
40
42
- Use the imperative mood in the summary line (e.g., "fix bug" not
41
43
"fixed bug" or "fixes bug").
42
44
- Try to keep the summary line under 72 characters, but we aren't too
+3
-3
flake.lock
+3
-3
flake.lock
···
48
48
"indigo": {
49
49
"flake": false,
50
50
"locked": {
51
-
"lastModified": 1738491661,
52
-
"narHash": "sha256-+njDigkvjH4XmXZMog5Mp0K4x9mamHX6gSGJCZB9mE4=",
51
+
"lastModified": 1745333930,
52
+
"narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=",
53
53
"owner": "oppiliappan",
54
54
"repo": "indigo",
55
-
"rev": "feb802f02a462ac0a6392ffc3e40b0529f0cdf71",
55
+
"rev": "e4e59280737b8676611fc077a228d47b3e8e9491",
56
56
"type": "github"
57
57
},
58
58
"original": {
+10
-1
flake.nix
+10
-1
flake.nix
···
173
173
${pkgs.air}/bin/air -c /dev/null \
174
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
175
-build.bin "./out/${name}.out" \
176
-
-build.include_ext "go,html,css"
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
177
182
'';
178
183
in {
179
184
watch-appview = {
···
183
188
watch-knotserver = {
184
189
type = "app";
185
190
program = ''${air-watcher "knotserver"}/bin/run'';
191
+
};
192
+
watch-tailwind = {
193
+
type = "app";
194
+
program = ''${tailwind-watcher}/bin/run'';
186
195
};
187
196
});
188
197
+4
-4
input.css
+4
-4
input.css
···
162
162
/* CommentSpecial */ .chroma .cs { color: #9ca0b0; font-style: italic }
163
163
/* CommentPreproc */ .chroma .cp { color: #9ca0b0; font-style: italic }
164
164
/* CommentPreprocFile */ .chroma .cpf { color: #9ca0b0; font-weight: bold; font-style: italic }
165
-
/* GenericDeleted */ .chroma .gd { color: #d20f39; background-color: #ccd0da }
165
+
/* GenericDeleted */ .chroma .gd { color: #d20f39; background-color: oklch(93.6% 0.032 17.717) }
166
166
/* GenericEmph */ .chroma .ge { font-style: italic }
167
167
/* GenericError */ .chroma .gr { color: #d20f39 }
168
168
/* GenericHeading */ .chroma .gh { color: #fe640b; font-weight: bold }
169
-
/* GenericInserted */ .chroma .gi { color: #40a02b; background-color: #ccd0da }
169
+
/* GenericInserted */ .chroma .gi { color: #40a02b; background-color: oklch(96.2% 0.044 156.743) }
170
170
/* GenericStrong */ .chroma .gs { font-weight: bold }
171
171
/* GenericSubheading */ .chroma .gu { color: #fe640b; font-weight: bold }
172
172
/* GenericTraceback */ .chroma .gt { color: #d20f39 }
···
239
239
/* CommentSpecial */ .chroma .cs { color: #6e738d; font-style: italic }
240
240
/* CommentPreproc */ .chroma .cp { color: #6e738d; font-style: italic }
241
241
/* CommentPreprocFile */ .chroma .cpf { color: #6e738d; font-weight: bold; font-style: italic }
242
-
/* GenericDeleted */ .chroma .gd { color: #ed8796; background-color: #363a4f }
242
+
/* GenericDeleted */ .chroma .gd { color: #ed8796; background-color: oklch(44.4% 0.177 26.899 / 0.5) }
243
243
/* GenericEmph */ .chroma .ge { font-style: italic }
244
244
/* GenericError */ .chroma .gr { color: #ed8796 }
245
245
/* GenericHeading */ .chroma .gh { color: #f5a97f; font-weight: bold }
246
-
/* GenericInserted */ .chroma .gi { color: #a6da95; background-color: #363a4f }
246
+
/* GenericInserted */ .chroma .gi { color: #a6da95; background-color: oklch(44.8% 0.119 151.328 / 0.5) }
247
247
/* GenericStrong */ .chroma .gs { font-weight: bold }
248
248
/* GenericSubheading */ .chroma .gu { color: #f5a97f; font-weight: bold }
249
249
/* GenericTraceback */ .chroma .gt { color: #ed8796 }
+8
patchutil/interdiff.go
+8
patchutil/interdiff.go
···
11
11
Files []*InterdiffFile
12
12
}
13
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
+
14
22
func (i *InterdiffResult) String() string {
15
23
var b strings.Builder
16
24
for _, f := range i.Files {
+14
types/diff.go
+14
types/diff.go
···
59
59
Patch string `json:"patch"`
60
60
Diff []*gitdiff.File `json:"diff"`
61
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
+
}