+8
-5
appview/db/issues.go
+8
-5
appview/db/issues.go
···
101
101
pLower := FilterGte("row_num", page.Offset+1)
102
102
pUpper := FilterLte("row_num", page.Offset+page.Limit)
103
103
104
-
args = append(args, pLower.Arg()...)
105
-
args = append(args, pUpper.Arg()...)
106
-
pagination := " where " + pLower.Condition() + " and " + pUpper.Condition()
104
+
pageClause := ""
105
+
if page.Limit > 0 {
106
+
args = append(args, pLower.Arg()...)
107
+
args = append(args, pUpper.Arg()...)
108
+
pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition()
109
+
}
107
110
108
111
query := fmt.Sprintf(
109
112
`
···
128
131
%s
129
132
`,
130
133
whereClause,
131
-
pagination,
134
+
pageClause,
132
135
)
133
136
134
137
rows, err := e.Query(query, args...)
···
244
247
}
245
248
246
249
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
247
-
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
250
+
return GetIssuesPaginated(e, pagination.Page{}, filters...)
248
251
}
249
252
250
253
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
+7
-4
appview/db/notifications.go
+7
-4
appview/db/notifications.go
···
60
60
whereClause += " AND " + condition
61
61
}
62
62
}
63
+
pageClause := ""
64
+
if page.Limit > 0 {
65
+
pageClause = " limit ? offset ? "
66
+
args = append(args, page.Limit, page.Offset)
67
+
}
63
68
64
69
query := fmt.Sprintf(`
65
70
select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id
66
71
from notifications
67
72
%s
68
73
order by created desc
69
-
limit ? offset ?
70
-
`, whereClause)
71
-
72
-
args = append(args, page.Limit, page.Offset)
74
+
%s
75
+
`, whereClause, pageClause)
73
76
74
77
rows, err := e.QueryContext(context.Background(), query, args...)
75
78
if err != nil {
+1
-5
appview/issues/issues.go
+1
-5
appview/issues/issues.go
···
770
770
isOpen = true
771
771
}
772
772
773
-
page, ok := r.Context().Value("page").(pagination.Page)
774
-
if !ok {
775
-
l.Error("failed to get page")
776
-
page = pagination.FirstPage()
777
-
}
773
+
page := pagination.FromContext(r.Context())
778
774
779
775
user := rp.oauth.GetUser(r)
780
776
f, err := rp.repoResolver.Resolve(r)
+1
-1
appview/middleware/middleware.go
+1
-1
appview/middleware/middleware.go
+1
-5
appview/notifications/notifications.go
+1
-5
appview/notifications/notifications.go
···
49
49
l := n.logger.With("handler", "notificationsPage")
50
50
user := n.oauth.GetUser(r)
51
51
52
-
page, ok := r.Context().Value("page").(pagination.Page)
53
-
if !ok {
54
-
l.Error("failed to get page")
55
-
page = pagination.FirstPage()
56
-
}
52
+
page := pagination.FromContext(r.Context())
57
53
58
54
total, err := db.CountNotifications(
59
55
n.db,
+42
-50
appview/notify/merged_notifier.go
+42
-50
appview/notify/merged_notifier.go
···
2
2
3
3
import (
4
4
"context"
5
+
"reflect"
6
+
"sync"
5
7
6
8
"tangled.org/core/appview/models"
7
9
)
···
16
18
17
19
var _ Notifier = &mergedNotifier{}
18
20
19
-
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
20
-
for _, notifier := range m.notifiers {
21
-
notifier.NewRepo(ctx, repo)
21
+
// fanout calls the same method on all notifiers concurrently
22
+
func (m *mergedNotifier) fanout(method string, args ...any) {
23
+
var wg sync.WaitGroup
24
+
for _, n := range m.notifiers {
25
+
wg.Add(1)
26
+
go func(notifier Notifier) {
27
+
defer wg.Done()
28
+
v := reflect.ValueOf(notifier).MethodByName(method)
29
+
in := make([]reflect.Value, len(args))
30
+
for i, arg := range args {
31
+
in[i] = reflect.ValueOf(arg)
32
+
}
33
+
v.Call(in)
34
+
}(n)
22
35
}
36
+
wg.Wait()
37
+
}
38
+
39
+
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
40
+
m.fanout("NewRepo", ctx, repo)
23
41
}
24
42
25
43
func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) {
26
-
for _, notifier := range m.notifiers {
27
-
notifier.NewStar(ctx, star)
28
-
}
44
+
m.fanout("NewStar", ctx, star)
29
45
}
46
+
30
47
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) {
31
-
for _, notifier := range m.notifiers {
32
-
notifier.DeleteStar(ctx, star)
33
-
}
48
+
m.fanout("DeleteStar", ctx, star)
34
49
}
35
50
36
51
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
37
-
for _, notifier := range m.notifiers {
38
-
notifier.NewIssue(ctx, issue)
39
-
}
52
+
m.fanout("NewIssue", ctx, issue)
40
53
}
54
+
41
55
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
42
-
for _, notifier := range m.notifiers {
43
-
notifier.NewIssueComment(ctx, comment)
44
-
}
56
+
m.fanout("NewIssueComment", ctx, comment)
45
57
}
46
58
47
59
func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
48
-
for _, notifier := range m.notifiers {
49
-
notifier.NewIssueClosed(ctx, issue)
50
-
}
60
+
m.fanout("NewIssueClosed", ctx, issue)
51
61
}
52
62
53
63
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
54
-
for _, notifier := range m.notifiers {
55
-
notifier.NewFollow(ctx, follow)
56
-
}
64
+
m.fanout("NewFollow", ctx, follow)
57
65
}
66
+
58
67
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
59
-
for _, notifier := range m.notifiers {
60
-
notifier.DeleteFollow(ctx, follow)
61
-
}
68
+
m.fanout("DeleteFollow", ctx, follow)
62
69
}
63
70
64
71
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) {
65
-
for _, notifier := range m.notifiers {
66
-
notifier.NewPull(ctx, pull)
67
-
}
72
+
m.fanout("NewPull", ctx, pull)
68
73
}
74
+
69
75
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
70
-
for _, notifier := range m.notifiers {
71
-
notifier.NewPullComment(ctx, comment)
72
-
}
76
+
m.fanout("NewPullComment", ctx, comment)
73
77
}
74
78
75
79
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
76
-
for _, notifier := range m.notifiers {
77
-
notifier.NewPullMerged(ctx, pull)
78
-
}
80
+
m.fanout("NewPullMerged", ctx, pull)
79
81
}
80
82
81
83
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
82
-
for _, notifier := range m.notifiers {
83
-
notifier.NewPullClosed(ctx, pull)
84
-
}
84
+
m.fanout("NewPullClosed", ctx, pull)
85
85
}
86
86
87
87
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
88
-
for _, notifier := range m.notifiers {
89
-
notifier.UpdateProfile(ctx, profile)
90
-
}
88
+
m.fanout("UpdateProfile", ctx, profile)
91
89
}
92
90
93
-
func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) {
94
-
for _, notifier := range m.notifiers {
95
-
notifier.NewString(ctx, string)
96
-
}
91
+
func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) {
92
+
m.fanout("NewString", ctx, s)
97
93
}
98
94
99
-
func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) {
100
-
for _, notifier := range m.notifiers {
101
-
notifier.EditString(ctx, string)
102
-
}
95
+
func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) {
96
+
m.fanout("EditString", ctx, s)
103
97
}
104
98
105
99
func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) {
106
-
for _, notifier := range m.notifiers {
107
-
notifier.DeleteString(ctx, did, rkey)
108
-
}
100
+
m.fanout("DeleteString", ctx, did, rkey)
109
101
}
+13
-2
appview/oauth/handler.go
+13
-2
appview/oauth/handler.go
···
4
4
"bytes"
5
5
"context"
6
6
"encoding/json"
7
+
"errors"
7
8
"fmt"
8
9
"net/http"
9
10
"slices"
10
11
"time"
11
12
13
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
12
14
"github.com/go-chi/chi/v5"
13
15
"github.com/lestrrat-go/jwx/v2/jwk"
14
16
"github.com/posthog/posthog-go"
···
58
60
59
61
func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
60
62
ctx := r.Context()
63
+
l := o.Logger.With("query", r.URL.Query())
61
64
62
65
sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query())
63
66
if err != nil {
64
-
http.Error(w, err.Error(), http.StatusInternalServerError)
67
+
var callbackErr *oauth.AuthRequestCallbackError
68
+
if errors.As(err, &callbackErr) {
69
+
l.Debug("callback error", "err", callbackErr)
70
+
http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound)
71
+
return
72
+
}
73
+
l.Error("failed to process callback", "err", err)
74
+
http.Redirect(w, r, "/login?error=oauth", http.StatusFound)
65
75
return
66
76
}
67
77
68
78
if err := o.SaveSession(w, r, sessData); err != nil {
69
-
http.Error(w, err.Error(), http.StatusInternalServerError)
79
+
l.Error("failed to save session", "data", sessData, "err", err)
80
+
http.Redirect(w, r, "/login?error=session", http.StatusFound)
70
81
return
71
82
}
72
83
+4
-1
appview/oauth/oauth.go
+4
-1
appview/oauth/oauth.go
···
58
58
59
59
sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret))
60
60
61
+
clientApp := oauth.NewClientApp(&oauthConfig, authStore)
62
+
clientApp.Dir = res.Directory()
63
+
61
64
return &OAuth{
62
-
ClientApp: oauth.NewClientApp(&oauthConfig, authStore),
65
+
ClientApp: clientApp,
63
66
Config: config,
64
67
SessStore: sessStore,
65
68
JwksUri: jwksUri,
+3
-2
appview/pages/funcmap.go
+3
-2
appview/pages/funcmap.go
···
297
297
},
298
298
299
299
"normalizeForHtmlId": func(s string) string {
300
-
// TODO: extend this to handle other cases?
301
-
return strings.ReplaceAll(s, ":", "_")
300
+
normalized := strings.ReplaceAll(s, ":", "_")
301
+
normalized = strings.ReplaceAll(normalized, ".", "_")
302
+
return normalized
302
303
},
303
304
"sshFingerprint": func(pubKey string) string {
304
305
fp, err := crypto.SSHFingerprint(pubKey)
+1
appview/pages/pages.go
+1
appview/pages/pages.go
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
34
34
35
35
{{ define "editIssueComment" }}
36
36
<a
37
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
37
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
38
38
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
39
39
hx-swap="outerHTML"
40
40
hx-target="#comment-body-{{.Comment.Id}}">
···
44
44
45
45
{{ define "deleteIssueComment" }}
46
46
<a
47
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
47
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
48
48
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
49
49
hx-confirm="Are you sure you want to delete your comment?"
50
50
hx-swap="outerHTML"
+2
-2
appview/pages/templates/repo/issues/issue.html
+2
-2
appview/pages/templates/repo/issues/issue.html
···
84
84
85
85
{{ define "editIssue" }}
86
86
<a
87
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
87
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
88
88
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
89
89
hx-swap="innerHTML"
90
90
hx-target="#issue-{{.Issue.IssueId}}">
···
94
94
95
95
{{ define "deleteIssue" }}
96
96
<a
97
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
97
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
98
98
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/"
99
99
hx-confirm="Are you sure you want to delete your issue?"
100
100
hx-swap="none">
+2
appview/pages/templates/repo/settings/access.html
+2
appview/pages/templates/repo/settings/access.html
+2
appview/pages/templates/spindles/fragments/addMemberModal.html
+2
appview/pages/templates/spindles/fragments/addMemberModal.html
+1
-1
appview/pages/templates/strings/string.html
+1
-1
appview/pages/templates/strings/string.html
···
47
47
</span>
48
48
</section>
49
49
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
50
-
<div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
50
+
<div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
51
51
<span>
52
52
{{ .String.Filename }}
53
53
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+1
-1
appview/pages/templates/user/fragments/followCard.html
+1
-1
appview/pages/templates/user/fragments/followCard.html
···
3
3
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
4
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
-
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
6
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" />
7
7
</div>
8
8
9
9
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
+23
-2
appview/pages/templates/user/login.html
+23
-2
appview/pages/templates/user/login.html
···
13
13
<title>login · tangled</title>
14
14
</head>
15
15
<body class="flex items-center justify-center min-h-screen">
16
-
<main class="max-w-md px-6 -mt-4">
16
+
<main class="max-w-md px-7 mt-4">
17
17
<h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" >
18
18
{{ template "fragments/logotype" }}
19
19
</h1>
···
21
21
tightly-knit social coding.
22
22
</h2>
23
23
<form
24
-
class="mt-4 max-w-sm mx-auto"
24
+
class="mt-4"
25
25
hx-post="/login"
26
26
hx-swap="none"
27
27
hx-disabled-elt="#login-button"
···
29
29
<div class="flex flex-col">
30
30
<label for="handle">handle</label>
31
31
<input
32
+
autocapitalize="none"
33
+
autocorrect="off"
34
+
autocomplete="username"
32
35
type="text"
33
36
id="handle"
34
37
name="handle"
···
53
56
<span>login</span>
54
57
</button>
55
58
</form>
59
+
{{ if .ErrorCode }}
60
+
<div class="flex gap-2 my-2 bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-3 py-2 text-red-500 dark:text-red-300">
61
+
<span class="py-1">{{ i "circle-alert" "w-4 h-4" }}</span>
62
+
<div>
63
+
<h5 class="font-medium">Login error</h5>
64
+
<p class="text-sm">
65
+
{{ if eq .ErrorCode "access_denied" }}
66
+
You have not authorized the app.
67
+
{{ else if eq .ErrorCode "session" }}
68
+
Server failed to create user session.
69
+
{{ else }}
70
+
Internal Server error.
71
+
{{ end }}
72
+
Please try again.
73
+
</p>
74
+
</div>
75
+
</div>
76
+
{{ end }}
56
77
<p class="text-sm text-gray-500">
57
78
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
58
79
</p>
+23
appview/pagination/page.go
+23
appview/pagination/page.go
···
1
1
package pagination
2
2
3
+
import "context"
4
+
3
5
type Page struct {
4
6
Offset int // where to start from
5
7
Limit int // number of items in a page
···
10
12
Offset: 0,
11
13
Limit: 30,
12
14
}
15
+
}
16
+
17
+
type ctxKey struct{}
18
+
19
+
func IntoContext(ctx context.Context, page Page) context.Context {
20
+
return context.WithValue(ctx, ctxKey{}, page)
21
+
}
22
+
23
+
func FromContext(ctx context.Context) Page {
24
+
if ctx == nil {
25
+
return FirstPage()
26
+
}
27
+
v := ctx.Value(ctxKey{})
28
+
if v == nil {
29
+
return FirstPage()
30
+
}
31
+
page, ok := v.(Page)
32
+
if !ok {
33
+
return FirstPage()
34
+
}
35
+
return page
13
36
}
14
37
15
38
func (p Page) Previous() Page {
+1
appview/state/follow.go
+1
appview/state/follow.go
+1
-4
appview/state/gfi.go
+1
-4
appview/state/gfi.go
···
18
18
func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) {
19
19
user := s.oauth.GetUser(r)
20
20
21
-
page, ok := r.Context().Value("page").(pagination.Page)
22
-
if !ok {
23
-
page = pagination.FirstPage()
24
-
}
21
+
page := pagination.FromContext(r.Context())
25
22
26
23
goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
27
24
+2
appview/state/login.go
+2
appview/state/login.go
···
14
14
switch r.Method {
15
15
case http.MethodGet:
16
16
returnURL := r.URL.Query().Get("return_url")
17
+
errorCode := r.URL.Query().Get("error")
17
18
s.pages.Login(w, pages.LoginParams{
18
19
ReturnUrl: returnURL,
20
+
ErrorCode: errorCode,
19
21
})
20
22
case http.MethodPost:
21
23
handle := r.FormValue("handle")
+1
-1
spindle/engines/nixery/engine.go
+1
-1
spindle/engines/nixery/engine.go
···
222
222
},
223
223
ReadonlyRootfs: false,
224
224
CapDrop: []string{"ALL"},
225
-
CapAdd: []string{"CAP_DAC_OVERRIDE"},
225
+
CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"},
226
226
SecurityOpt: []string{"no-new-privileges"},
227
227
ExtraHosts: []string{"host.docker.internal:host-gateway"},
228
228
}, nil, nil, "")