+13
.editorconfig
+13
.editorconfig
+13
-1
api/tangled/repoblob.go
+13
-1
api/tangled/repoblob.go
···
30
30
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
31
31
type RepoBlob_Output struct {
32
32
// content: File content (base64 encoded for binary files)
33
-
Content string `json:"content" cborgen:"content"`
33
+
Content *string `json:"content,omitempty" cborgen:"content,omitempty"`
34
34
// encoding: Content encoding
35
35
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
36
36
// isBinary: Whether the file is binary
···
44
44
Ref string `json:"ref" cborgen:"ref"`
45
45
// size: File size in bytes
46
46
Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"`
47
+
// submodule: Submodule information if path is a submodule
48
+
Submodule *RepoBlob_Submodule `json:"submodule,omitempty" cborgen:"submodule,omitempty"`
47
49
}
48
50
49
51
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
···
54
56
Name string `json:"name" cborgen:"name"`
55
57
// when: Author timestamp
56
58
When string `json:"when" cborgen:"when"`
59
+
}
60
+
61
+
// RepoBlob_Submodule is a "submodule" in the sh.tangled.repo.blob schema.
62
+
type RepoBlob_Submodule struct {
63
+
// branch: Branch to track in the submodule
64
+
Branch *string `json:"branch,omitempty" cborgen:"branch,omitempty"`
65
+
// name: Submodule name
66
+
Name string `json:"name" cborgen:"name"`
67
+
// url: Submodule repository URL
68
+
Url string `json:"url" cborgen:"url"`
57
69
}
58
70
59
71
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
-4
api/tangled/repotree.go
-4
api/tangled/repotree.go
···
47
47
48
48
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
49
49
type RepoTree_TreeEntry struct {
50
-
// is_file: Whether this entry is a file
51
-
Is_file bool `json:"is_file" cborgen:"is_file"`
52
-
// is_subtree: Whether this entry is a directory/subtree
53
-
Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"`
54
50
Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"`
55
51
// mode: File mode
56
52
Mode string `json:"mode" cborgen:"mode"`
+7
appview/db/db.go
+7
appview/db/db.go
···
1121
1121
return err
1122
1122
})
1123
1123
1124
+
runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error {
1125
+
_, err := tx.Exec(`
1126
+
alter table notification_preferences add column user_mentioned integer not null default 1;
1127
+
`)
1128
+
return err
1129
+
})
1130
+
1124
1131
return &DB{
1125
1132
db,
1126
1133
logger,
+6
-2
appview/db/notifications.go
+6
-2
appview/db/notifications.go
···
400
400
pull_created,
401
401
pull_commented,
402
402
followed,
403
+
user_mentioned,
403
404
pull_merged,
404
405
issue_closed,
405
406
email_notifications
···
425
426
&prefs.PullCreated,
426
427
&prefs.PullCommented,
427
428
&prefs.Followed,
429
+
&prefs.UserMentioned,
428
430
&prefs.PullMerged,
429
431
&prefs.IssueClosed,
430
432
&prefs.EmailNotifications,
···
446
448
query := `
447
449
INSERT OR REPLACE INTO notification_preferences
448
450
(user_did, repo_starred, issue_created, issue_commented, pull_created,
449
-
pull_commented, followed, pull_merged, issue_closed, email_notifications)
450
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
451
+
pull_commented, followed, user_mentioned, pull_merged, issue_closed,
452
+
email_notifications)
453
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
451
454
`
452
455
453
456
result, err := d.DB.ExecContext(ctx, query,
···
458
461
prefs.PullCreated,
459
462
prefs.PullCommented,
460
463
prefs.Followed,
464
+
prefs.UserMentioned,
461
465
prefs.PullMerged,
462
466
prefs.IssueClosed,
463
467
prefs.EmailNotifications,
+1
-1
appview/indexer/notifier.go
+1
-1
appview/indexer/notifier.go
···
11
11
12
12
var _ notify.Notifier = &Indexer{}
13
13
14
-
func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue) {
14
+
func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
15
15
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
16
16
l.Debug("indexing new issue")
17
17
err := ix.Issues.Index(ctx, *issue)
+23
-2
appview/issues/issues.go
+23
-2
appview/issues/issues.go
···
24
24
"tangled.org/core/appview/notify"
25
25
"tangled.org/core/appview/oauth"
26
26
"tangled.org/core/appview/pages"
27
+
"tangled.org/core/appview/pages/markup"
27
28
"tangled.org/core/appview/pagination"
28
29
"tangled.org/core/appview/reporesolver"
29
30
"tangled.org/core/appview/validator"
···
453
454
454
455
// notify about the new comment
455
456
comment.Id = commentId
456
-
rp.notifier.NewIssueComment(r.Context(), &comment)
457
+
458
+
rawMentions := markup.FindUserMentions(comment.Body)
459
+
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
460
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
461
+
var mentions []syntax.DID
462
+
for _, ident := range idents {
463
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
464
+
mentions = append(mentions, ident.DID)
465
+
}
466
+
}
467
+
rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
457
468
458
469
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
459
470
}
···
948
959
949
960
// everything is successful, do not rollback the atproto record
950
961
atUri = ""
951
-
rp.notifier.NewIssue(r.Context(), issue)
962
+
963
+
rawMentions := markup.FindUserMentions(issue.Body)
964
+
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
965
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
966
+
var mentions []syntax.DID
967
+
for _, ident := range idents {
968
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
969
+
mentions = append(mentions, ident.DID)
970
+
}
971
+
}
972
+
rp.notifier.NewIssue(r.Context(), issue, mentions)
952
973
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
953
974
return
954
975
}
+2
appview/middleware/middleware.go
+2
appview/middleware/middleware.go
···
206
206
return func(next http.Handler) http.Handler {
207
207
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
208
208
repoName := chi.URLParam(req, "repo")
209
+
repoName = strings.TrimSuffix(repoName, ".git")
210
+
209
211
id, ok := req.Context().Value("resolvedId").(identity.Identity)
210
212
if !ok {
211
213
log.Println("malformed middleware")
+7
appview/models/notifications.go
+7
appview/models/notifications.go
···
20
20
NotificationTypeIssueReopen NotificationType = "issue_reopen"
21
21
NotificationTypePullClosed NotificationType = "pull_closed"
22
22
NotificationTypePullReopen NotificationType = "pull_reopen"
23
+
NotificationTypeUserMentioned NotificationType = "user_mentioned"
23
24
)
24
25
25
26
type Notification struct {
···
63
64
return "git-pull-request-create"
64
65
case NotificationTypeFollowed:
65
66
return "user-plus"
67
+
case NotificationTypeUserMentioned:
68
+
return "at-sign"
66
69
default:
67
70
return ""
68
71
}
···
84
87
PullCreated bool
85
88
PullCommented bool
86
89
Followed bool
90
+
UserMentioned bool
87
91
PullMerged bool
88
92
IssueClosed bool
89
93
EmailNotifications bool
···
113
117
return prefs.PullCreated // same pref for now
114
118
case NotificationTypeFollowed:
115
119
return prefs.Followed
120
+
case NotificationTypeUserMentioned:
121
+
return prefs.UserMentioned
116
122
default:
117
123
return false
118
124
}
···
127
133
PullCreated: true,
128
134
PullCommented: true,
129
135
Followed: true,
136
+
UserMentioned: true,
130
137
PullMerged: true,
131
138
IssueClosed: true,
132
139
EmailNotifications: false,
+47
appview/models/repo.go
+47
appview/models/repo.go
···
104
104
Repo *Repo
105
105
Issues []Issue
106
106
}
107
+
108
+
type BlobContentType int
109
+
110
+
const (
111
+
BlobContentTypeCode BlobContentType = iota
112
+
BlobContentTypeMarkup
113
+
BlobContentTypeImage
114
+
BlobContentTypeSvg
115
+
BlobContentTypeVideo
116
+
BlobContentTypeSubmodule
117
+
)
118
+
119
+
func (ty BlobContentType) IsCode() bool { return ty == BlobContentTypeCode }
120
+
func (ty BlobContentType) IsMarkup() bool { return ty == BlobContentTypeMarkup }
121
+
func (ty BlobContentType) IsImage() bool { return ty == BlobContentTypeImage }
122
+
func (ty BlobContentType) IsSvg() bool { return ty == BlobContentTypeSvg }
123
+
func (ty BlobContentType) IsVideo() bool { return ty == BlobContentTypeVideo }
124
+
func (ty BlobContentType) IsSubmodule() bool { return ty == BlobContentTypeSubmodule }
125
+
126
+
type BlobView struct {
127
+
HasTextView bool // can show as code/text
128
+
HasRenderedView bool // can show rendered (markup/image/video/submodule)
129
+
HasRawView bool // can download raw (everything except submodule)
130
+
131
+
// current display mode
132
+
ShowingRendered bool // currently in rendered mode
133
+
ShowingText bool // currently in text/code mode
134
+
135
+
// content type flags
136
+
ContentType BlobContentType
137
+
138
+
// Content data
139
+
Contents string
140
+
ContentSrc string // URL for media files
141
+
Lines int
142
+
SizeHint uint64
143
+
}
144
+
145
+
// if both views are available, then show a toggle between them
146
+
func (b BlobView) ShowToggle() bool {
147
+
return b.HasTextView && b.HasRenderedView
148
+
}
149
+
150
+
func (b BlobView) IsUnsupported() bool {
151
+
// no view available, only raw
152
+
return !(b.HasRenderedView || b.HasTextView)
153
+
}
+42
-7
appview/notify/db/db.go
+42
-7
appview/notify/db/db.go
···
13
13
"tangled.org/core/idresolver"
14
14
)
15
15
16
+
const (
17
+
maxMentions = 5
18
+
)
19
+
16
20
type databaseNotifier struct {
17
21
db *db.DB
18
22
res *idresolver.Resolver
···
64
68
// no-op
65
69
}
66
70
67
-
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
71
+
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
68
72
69
73
// build the recipients list
70
74
// - owner of the repo
···
81
85
}
82
86
83
87
actorDid := syntax.DID(issue.Did)
84
-
eventType := models.NotificationTypeIssueCreated
85
88
entityType := "issue"
86
89
entityId := issue.AtUri().String()
87
90
repoId := &issue.Repo.Id
···
91
94
n.notifyEvent(
92
95
actorDid,
93
96
recipients,
94
-
eventType,
97
+
models.NotificationTypeIssueCreated,
98
+
entityType,
99
+
entityId,
100
+
repoId,
101
+
issueId,
102
+
pullId,
103
+
)
104
+
n.notifyEvent(
105
+
actorDid,
106
+
mentions,
107
+
models.NotificationTypeUserMentioned,
95
108
entityType,
96
109
entityId,
97
110
repoId,
···
100
113
)
101
114
}
102
115
103
-
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
116
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
104
117
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
105
118
if err != nil {
106
119
log.Printf("NewIssueComment: failed to get issues: %v", err)
···
132
145
}
133
146
134
147
actorDid := syntax.DID(comment.Did)
135
-
eventType := models.NotificationTypeIssueCommented
136
148
entityType := "issue"
137
149
entityId := issue.AtUri().String()
138
150
repoId := &issue.Repo.Id
···
142
154
n.notifyEvent(
143
155
actorDid,
144
156
recipients,
145
-
eventType,
157
+
models.NotificationTypeIssueCommented,
158
+
entityType,
159
+
entityId,
160
+
repoId,
161
+
issueId,
162
+
pullId,
163
+
)
164
+
n.notifyEvent(
165
+
actorDid,
166
+
mentions,
167
+
models.NotificationTypeUserMentioned,
146
168
entityType,
147
169
entityId,
148
170
repoId,
···
221
243
)
222
244
}
223
245
224
-
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
246
+
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
225
247
pull, err := db.GetPull(n.db,
226
248
syntax.ATURI(comment.RepoAt),
227
249
comment.PullId,
···
265
287
issueId,
266
288
pullId,
267
289
)
290
+
n.notifyEvent(
291
+
actorDid,
292
+
mentions,
293
+
models.NotificationTypeUserMentioned,
294
+
entityType,
295
+
entityId,
296
+
repoId,
297
+
issueId,
298
+
pullId,
299
+
)
268
300
}
269
301
270
302
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
···
393
425
issueId *int64,
394
426
pullId *int64,
395
427
) {
428
+
if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions {
429
+
recipients = recipients[:maxMentions]
430
+
}
396
431
recipientSet := make(map[syntax.DID]struct{})
397
432
for _, did := range recipients {
398
433
// everybody except actor themselves
+6
-6
appview/notify/merged_notifier.go
+6
-6
appview/notify/merged_notifier.go
···
54
54
m.fanout("DeleteStar", ctx, star)
55
55
}
56
56
57
-
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
58
-
m.fanout("NewIssue", ctx, issue)
57
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
58
+
m.fanout("NewIssue", ctx, issue, mentions)
59
59
}
60
60
61
-
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
62
-
m.fanout("NewIssueComment", ctx, comment)
61
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
62
+
m.fanout("NewIssueComment", ctx, comment, mentions)
63
63
}
64
64
65
65
func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
···
82
82
m.fanout("NewPull", ctx, pull)
83
83
}
84
84
85
-
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
86
-
m.fanout("NewPullComment", ctx, comment)
85
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
86
+
m.fanout("NewPullComment", ctx, comment, mentions)
87
87
}
88
88
89
89
func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
+9
-7
appview/notify/notifier.go
+9
-7
appview/notify/notifier.go
···
13
13
NewStar(ctx context.Context, star *models.Star)
14
14
DeleteStar(ctx context.Context, star *models.Star)
15
15
16
-
NewIssue(ctx context.Context, issue *models.Issue)
17
-
NewIssueComment(ctx context.Context, comment *models.IssueComment)
16
+
NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID)
17
+
NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID)
18
18
NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue)
19
19
DeleteIssue(ctx context.Context, issue *models.Issue)
20
20
···
22
22
DeleteFollow(ctx context.Context, follow *models.Follow)
23
23
24
24
NewPull(ctx context.Context, pull *models.Pull)
25
-
NewPullComment(ctx context.Context, comment *models.PullComment)
25
+
NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID)
26
26
NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull)
27
27
28
28
UpdateProfile(ctx context.Context, profile *models.Profile)
···
42
42
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
43
43
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
44
44
45
-
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {}
46
-
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {}
45
+
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {}
46
+
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
47
+
}
47
48
func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {}
48
49
func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {}
49
50
50
51
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
51
52
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
52
53
53
-
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
54
-
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
54
+
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
55
+
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) {
56
+
}
55
57
func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {}
56
58
57
59
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
+8
-5
appview/notify/posthog/notifier.go
+8
-5
appview/notify/posthog/notifier.go
···
57
57
}
58
58
}
59
59
60
-
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
60
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
61
61
err := n.client.Enqueue(posthog.Capture{
62
62
DistinctId: issue.Did,
63
63
Event: "new_issue",
64
64
Properties: posthog.Properties{
65
65
"repo_at": issue.RepoAt.String(),
66
66
"issue_id": issue.IssueId,
67
+
"mentions": mentions,
67
68
},
68
69
})
69
70
if err != nil {
···
85
86
}
86
87
}
87
88
88
-
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
89
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
89
90
err := n.client.Enqueue(posthog.Capture{
90
91
DistinctId: comment.OwnerDid,
91
92
Event: "new_pull_comment",
92
93
Properties: posthog.Properties{
93
-
"repo_at": comment.RepoAt,
94
-
"pull_id": comment.PullId,
94
+
"repo_at": comment.RepoAt,
95
+
"pull_id": comment.PullId,
96
+
"mentions": mentions,
95
97
},
96
98
})
97
99
if err != nil {
···
178
180
}
179
181
}
180
182
181
-
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
183
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
182
184
err := n.client.Enqueue(posthog.Capture{
183
185
DistinctId: comment.Did,
184
186
Event: "new_issue_comment",
185
187
Properties: posthog.Properties{
186
188
"issue_at": comment.IssueAt,
189
+
"mentions": mentions,
187
190
},
188
191
})
189
192
if err != nil {
+41
appview/pages/funcmap.go
+41
appview/pages/funcmap.go
···
1
1
package pages
2
2
3
3
import (
4
+
"bytes"
4
5
"context"
5
6
"crypto/hmac"
6
7
"crypto/sha256"
···
17
18
"strings"
18
19
"time"
19
20
21
+
"github.com/alecthomas/chroma/v2"
22
+
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
23
+
"github.com/alecthomas/chroma/v2/lexers"
24
+
"github.com/alecthomas/chroma/v2/styles"
20
25
"github.com/bluesky-social/indigo/atproto/syntax"
21
26
"github.com/dustin/go-humanize"
22
27
"github.com/go-enry/go-enry/v2"
···
245
250
htmlString := p.rctx.RenderMarkdown(text)
246
251
sanitized := p.rctx.SanitizeDescription(htmlString)
247
252
return template.HTML(sanitized)
253
+
},
254
+
"readme": func(text string) template.HTML {
255
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
256
+
htmlString := p.rctx.RenderMarkdown(text)
257
+
sanitized := p.rctx.SanitizeDefault(htmlString)
258
+
return template.HTML(sanitized)
259
+
},
260
+
"code": func(content, path string) string {
261
+
var style *chroma.Style = styles.Get("catpuccin-latte")
262
+
formatter := chromahtml.New(
263
+
chromahtml.InlineCode(false),
264
+
chromahtml.WithLineNumbers(true),
265
+
chromahtml.WithLinkableLineNumbers(true, "L"),
266
+
chromahtml.Standalone(false),
267
+
chromahtml.WithClasses(true),
268
+
)
269
+
270
+
lexer := lexers.Get(filepath.Base(path))
271
+
if lexer == nil {
272
+
lexer = lexers.Fallback
273
+
}
274
+
275
+
iterator, err := lexer.Tokenise(nil, content)
276
+
if err != nil {
277
+
p.logger.Error("chroma tokenize", "err", "err")
278
+
return ""
279
+
}
280
+
281
+
var code bytes.Buffer
282
+
err = formatter.Format(&code, style, iterator)
283
+
if err != nil {
284
+
p.logger.Error("chroma format", "err", "err")
285
+
return ""
286
+
}
287
+
288
+
return code.String()
248
289
},
249
290
"trimUriScheme": func(text string) string {
250
291
text = strings.TrimPrefix(text, "https://")
+111
appview/pages/markup/extension/atlink.go
+111
appview/pages/markup/extension/atlink.go
···
1
+
// heavily inspired by: https://github.com/kaleocheng/goldmark-extensions
2
+
3
+
package extension
4
+
5
+
import (
6
+
"regexp"
7
+
8
+
"github.com/yuin/goldmark"
9
+
"github.com/yuin/goldmark/ast"
10
+
"github.com/yuin/goldmark/parser"
11
+
"github.com/yuin/goldmark/renderer"
12
+
"github.com/yuin/goldmark/renderer/html"
13
+
"github.com/yuin/goldmark/text"
14
+
"github.com/yuin/goldmark/util"
15
+
)
16
+
17
+
// An AtNode struct represents an AtNode
18
+
type AtNode struct {
19
+
Handle string
20
+
ast.BaseInline
21
+
}
22
+
23
+
var _ ast.Node = &AtNode{}
24
+
25
+
// Dump implements Node.Dump.
26
+
func (n *AtNode) Dump(source []byte, level int) {
27
+
ast.DumpHelper(n, source, level, nil, nil)
28
+
}
29
+
30
+
// KindAt is a NodeKind of the At node.
31
+
var KindAt = ast.NewNodeKind("At")
32
+
33
+
// Kind implements Node.Kind.
34
+
func (n *AtNode) Kind() ast.NodeKind {
35
+
return KindAt
36
+
}
37
+
38
+
var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`)
39
+
40
+
type atParser struct{}
41
+
42
+
// NewAtParser return a new InlineParser that parses
43
+
// at expressions.
44
+
func NewAtParser() parser.InlineParser {
45
+
return &atParser{}
46
+
}
47
+
48
+
func (s *atParser) Trigger() []byte {
49
+
return []byte{'@'}
50
+
}
51
+
52
+
func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
53
+
line, segment := block.PeekLine()
54
+
m := atRegexp.FindSubmatchIndex(line)
55
+
if m == nil {
56
+
return nil
57
+
}
58
+
atSegment := text.NewSegment(segment.Start, segment.Start+m[1])
59
+
block.Advance(m[1])
60
+
node := &AtNode{}
61
+
node.AppendChild(node, ast.NewTextSegment(atSegment))
62
+
node.Handle = string(atSegment.Value(block.Source())[1:])
63
+
return node
64
+
}
65
+
66
+
// atHtmlRenderer is a renderer.NodeRenderer implementation that
67
+
// renders At nodes.
68
+
type atHtmlRenderer struct {
69
+
html.Config
70
+
}
71
+
72
+
// NewAtHTMLRenderer returns a new AtHTMLRenderer.
73
+
func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
74
+
r := &atHtmlRenderer{
75
+
Config: html.NewConfig(),
76
+
}
77
+
for _, opt := range opts {
78
+
opt.SetHTMLOption(&r.Config)
79
+
}
80
+
return r
81
+
}
82
+
83
+
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
84
+
func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
85
+
reg.Register(KindAt, r.renderAt)
86
+
}
87
+
88
+
func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
89
+
if entering {
90
+
w.WriteString(`<a href="/@`)
91
+
w.WriteString(n.(*AtNode).Handle)
92
+
w.WriteString(`" class="mention">`)
93
+
} else {
94
+
w.WriteString("</a>")
95
+
}
96
+
return ast.WalkContinue, nil
97
+
}
98
+
99
+
type atExt struct{}
100
+
101
+
// At is an extension that allow you to use at expression like '@user.bsky.social' .
102
+
var AtExt = &atExt{}
103
+
104
+
func (e *atExt) Extend(m goldmark.Markdown) {
105
+
m.Parser().AddOptions(parser.WithInlineParsers(
106
+
util.Prioritized(NewAtParser(), 500),
107
+
))
108
+
m.Renderer().AddOptions(renderer.WithNodeRenderers(
109
+
util.Prioritized(NewAtHTMLRenderer(), 500),
110
+
))
111
+
}
+32
-1
appview/pages/markup/markdown.go
+32
-1
appview/pages/markup/markdown.go
···
25
25
htmlparse "golang.org/x/net/html"
26
26
27
27
"tangled.org/core/api/tangled"
28
+
textension "tangled.org/core/appview/pages/markup/extension"
28
29
"tangled.org/core/appview/pages/repoinfo"
29
30
)
30
31
···
50
51
Files fs.FS
51
52
}
52
53
53
-
func (rctx *RenderContext) RenderMarkdown(source string) string {
54
+
func NewMarkdown() goldmark.Markdown {
54
55
md := goldmark.New(
55
56
goldmark.WithExtensions(
56
57
extension.GFM,
···
66
67
),
67
68
treeblood.MathML(),
68
69
callout.CalloutExtention,
70
+
textension.AtExt,
69
71
),
70
72
goldmark.WithParserOptions(
71
73
parser.WithAutoHeadingID(),
72
74
),
73
75
goldmark.WithRendererOptions(html.WithUnsafe()),
74
76
)
77
+
return md
78
+
}
79
+
80
+
func (rctx *RenderContext) RenderMarkdown(source string) string {
81
+
md := NewMarkdown()
75
82
76
83
if rctx != nil {
77
84
var transformers []util.PrioritizedValue
···
293
300
}
294
301
295
302
return path.Join(rctx.CurrentDir, dst)
303
+
}
304
+
305
+
// FindUserMentions returns Set of user handles from given markup soruce.
306
+
// It doesn't guarntee unique DIDs
307
+
func FindUserMentions(source string) []string {
308
+
var (
309
+
mentions []string
310
+
mentionsSet = make(map[string]struct{})
311
+
md = NewMarkdown()
312
+
sourceBytes = []byte(source)
313
+
root = md.Parser().Parse(text.NewReader(sourceBytes))
314
+
)
315
+
ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
316
+
if entering && n.Kind() == textension.KindAt {
317
+
handle := n.(*textension.AtNode).Handle
318
+
mentionsSet[handle] = struct{}{}
319
+
return ast.WalkSkipChildren, nil
320
+
}
321
+
return ast.WalkContinue, nil
322
+
})
323
+
for handle := range mentionsSet {
324
+
mentions = append(mentions, handle)
325
+
}
326
+
return mentions
296
327
}
297
328
298
329
func isAbsoluteUrl(link string) bool {
+3
appview/pages/markup/sanitizer.go
+3
appview/pages/markup/sanitizer.go
···
77
77
policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8")
78
78
policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span")
79
79
80
+
// at-mentions
81
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a")
82
+
80
83
// centering content
81
84
policy.AllowElements("center")
82
85
+10
-98
appview/pages/pages.go
+10
-98
appview/pages/pages.go
···
1
1
package pages
2
2
3
3
import (
4
-
"bytes"
5
4
"crypto/sha256"
6
5
"embed"
7
6
"encoding/hex"
···
29
28
"tangled.org/core/patchutil"
30
29
"tangled.org/core/types"
31
30
32
-
"github.com/alecthomas/chroma/v2"
33
-
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
34
-
"github.com/alecthomas/chroma/v2/lexers"
35
-
"github.com/alecthomas/chroma/v2/styles"
36
31
"github.com/bluesky-social/indigo/atproto/identity"
37
32
"github.com/bluesky-social/indigo/atproto/syntax"
38
33
"github.com/go-git/go-git/v5/plumbing"
···
744
739
func (r RepoTreeParams) TreeStats() RepoTreeStats {
745
740
numFolders, numFiles := 0, 0
746
741
for _, f := range r.Files {
747
-
if !f.IsFile {
742
+
if !f.IsFile() {
748
743
numFolders += 1
749
-
} else if f.IsFile {
744
+
} else if f.IsFile() {
750
745
numFiles += 1
751
746
}
752
747
}
···
817
812
}
818
813
819
814
type RepoBlobParams struct {
820
-
LoggedInUser *oauth.User
821
-
RepoInfo repoinfo.RepoInfo
822
-
Active string
823
-
Unsupported bool
824
-
IsImage bool
825
-
IsVideo bool
826
-
ContentSrc string
827
-
BreadCrumbs [][]string
828
-
ShowRendered bool
829
-
RenderToggle bool
830
-
RenderedContents template.HTML
815
+
LoggedInUser *oauth.User
816
+
RepoInfo repoinfo.RepoInfo
817
+
Active string
818
+
BreadCrumbs [][]string
819
+
BlobView models.BlobView
831
820
*tangled.RepoBlob_Output
832
-
// Computed fields for template compatibility
833
-
Contents string
834
-
Lines int
835
-
SizeHint uint64
836
-
IsBinary bool
837
821
}
838
822
839
823
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
840
-
var style *chroma.Style = styles.Get("catpuccin-latte")
841
-
842
-
if params.ShowRendered {
843
-
switch markup.GetFormat(params.Path) {
844
-
case markup.FormatMarkdown:
845
-
p.rctx.RepoInfo = params.RepoInfo
846
-
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
847
-
htmlString := p.rctx.RenderMarkdown(params.Contents)
848
-
sanitized := p.rctx.SanitizeDefault(htmlString)
849
-
params.RenderedContents = template.HTML(sanitized)
850
-
}
851
-
}
852
-
853
-
c := params.Contents
854
-
formatter := chromahtml.New(
855
-
chromahtml.InlineCode(false),
856
-
chromahtml.WithLineNumbers(true),
857
-
chromahtml.WithLinkableLineNumbers(true, "L"),
858
-
chromahtml.Standalone(false),
859
-
chromahtml.WithClasses(true),
860
-
)
861
-
862
-
lexer := lexers.Get(filepath.Base(params.Path))
863
-
if lexer == nil {
864
-
lexer = lexers.Fallback
865
-
}
866
-
867
-
iterator, err := lexer.Tokenise(nil, c)
868
-
if err != nil {
869
-
return fmt.Errorf("chroma tokenize: %w", err)
870
-
}
871
-
872
-
var code bytes.Buffer
873
-
err = formatter.Format(&code, style, iterator)
874
-
if err != nil {
875
-
return fmt.Errorf("chroma format: %w", err)
824
+
switch params.BlobView.ContentType {
825
+
case models.BlobContentTypeMarkup:
826
+
p.rctx.RepoInfo = params.RepoInfo
876
827
}
877
828
878
-
params.Contents = code.String()
879
829
params.Active = "overview"
880
830
return p.executeRepo("repo/blob", w, params)
881
831
}
···
1432
1382
}
1433
1383
1434
1384
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1435
-
var style *chroma.Style = styles.Get("catpuccin-latte")
1436
-
1437
-
if params.ShowRendered {
1438
-
switch markup.GetFormat(params.String.Filename) {
1439
-
case markup.FormatMarkdown:
1440
-
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
1441
-
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
1442
-
sanitized := p.rctx.SanitizeDefault(htmlString)
1443
-
params.RenderedContents = template.HTML(sanitized)
1444
-
}
1445
-
}
1446
-
1447
-
c := params.String.Contents
1448
-
formatter := chromahtml.New(
1449
-
chromahtml.InlineCode(false),
1450
-
chromahtml.WithLineNumbers(true),
1451
-
chromahtml.WithLinkableLineNumbers(true, "L"),
1452
-
chromahtml.Standalone(false),
1453
-
chromahtml.WithClasses(true),
1454
-
)
1455
-
1456
-
lexer := lexers.Get(filepath.Base(params.String.Filename))
1457
-
if lexer == nil {
1458
-
lexer = lexers.Fallback
1459
-
}
1460
-
1461
-
iterator, err := lexer.Tokenise(nil, c)
1462
-
if err != nil {
1463
-
return fmt.Errorf("chroma tokenize: %w", err)
1464
-
}
1465
-
1466
-
var code bytes.Buffer
1467
-
err = formatter.Format(&code, style, iterator)
1468
-
if err != nil {
1469
-
return fmt.Errorf("chroma format: %w", err)
1470
-
}
1471
-
1472
-
params.String.Contents = code.String()
1473
1385
return p.execute("strings/string", w, params)
1474
1386
}
1475
1387
+17
-12
appview/pages/templates/knots/fragments/addMemberModal.html
+17
-12
appview/pages/templates/knots/fragments/addMemberModal.html
···
13
13
<div
14
14
id="add-member-{{ .Id }}"
15
15
popover
16
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
16
+
class="
17
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
18
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
17
19
{{ block "addKnotMemberPopover" . }} {{ end }}
18
20
</div>
19
21
{{ end }}
···
29
31
ADD MEMBER
30
32
</label>
31
33
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
32
-
<input
33
-
autocapitalize="none"
34
-
autocorrect="off"
35
-
autocomplete="off"
36
-
type="text"
37
-
id="member-did-{{ .Id }}"
38
-
name="member"
39
-
required
40
-
placeholder="foo.bsky.social"
41
-
/>
34
+
<actor-typeahead>
35
+
<input
36
+
autocapitalize="none"
37
+
autocorrect="off"
38
+
autocomplete="off"
39
+
type="text"
40
+
id="member-did-{{ .Id }}"
41
+
name="member"
42
+
required
43
+
placeholder="user.tngl.sh"
44
+
class="w-full"
45
+
/>
46
+
</actor-typeahead>
42
47
<div class="flex gap-2 pt-2">
43
48
<button
44
49
type="button"
···
57
62
</div>
58
63
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
59
64
</form>
60
-
{{ end }}
65
+
{{ end }}
+1
appview/pages/templates/layouts/base.html
+1
appview/pages/templates/layouts/base.html
···
9
9
10
10
<script defer src="/static/htmx.min.js"></script>
11
11
<script defer src="/static/htmx-ext-ws.min.js"></script>
12
+
<script defer src="/static/actor-typeahead.js" type="module"></script>
12
13
13
14
<!-- preconnect to image cdn -->
14
15
<link rel="preconnect" href="https://avatar.tangled.sh" />
+11
-8
appview/pages/templates/layouts/repobase.html
+11
-8
appview/pages/templates/layouts/repobase.html
···
2
2
3
3
{{ define "content" }}
4
4
<section id="repo-header" class="mb-4 p-2 dark:text-white">
5
-
{{ if .RepoInfo.Source }}
6
-
<div class="flex items-center">
7
-
{{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }}
8
-
forked from
9
-
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
10
-
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
11
-
</div>
12
-
{{ end }}
13
5
<div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between">
14
6
<!-- left items -->
15
7
<div class="flex flex-col gap-2">
···
19
11
<span class="select-none">/</span>
20
12
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
21
13
</div>
14
+
15
+
{{ if .RepoInfo.Source }}
16
+
{{ $sourceOwner := resolve .RepoInfo.Source.Did }}
17
+
<div class="flex items-center gap-1 text-sm flex-wrap">
18
+
{{ i "git-fork" "w-3 h-3 shrink-0" }}
19
+
<span>forked from</span>
20
+
<a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">
21
+
{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}
22
+
</a>
23
+
</div>
24
+
{{ end }}
22
25
23
26
<span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300">
24
27
{{ if .RepoInfo.Description }}
+2
appview/pages/templates/notifications/fragments/item.html
+2
appview/pages/templates/notifications/fragments/item.html
+62
-39
appview/pages/templates/repo/blob.html
+62
-39
appview/pages/templates/repo/blob.html
···
11
11
{{ end }}
12
12
13
13
{{ define "repoContent" }}
14
-
{{ $lines := split .Contents }}
15
-
{{ $tot_lines := len $lines }}
16
-
{{ $tot_chars := len (printf "%d" $tot_lines) }}
17
-
{{ $code_number_style := "text-gray-400 dark:text-gray-500 left-0 bg-white dark:bg-gray-800 text-right mr-6 select-none inline-block w-12" }}
18
14
{{ $linkstyle := "no-underline hover:underline" }}
19
15
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
20
16
<div class="flex flex-col md:flex-row md:justify-between gap-2">
···
36
32
</div>
37
33
<div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
38
34
<span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span>
39
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
40
-
<span>{{ .Lines }} lines</span>
41
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
42
-
<span>{{ byteFmt .SizeHint }}</span>
43
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
44
-
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
45
-
{{ if .RenderToggle }}
46
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
47
-
<a
48
-
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
49
-
hx-boost="true"
50
-
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
35
+
36
+
{{ if .BlobView.ShowingText }}
37
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
38
+
<span>{{ .Lines }} lines</span>
39
+
{{ end }}
40
+
41
+
{{ if .BlobView.SizeHint }}
42
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
43
+
<span>{{ byteFmt .BlobView.SizeHint }}</span>
44
+
{{ end }}
45
+
46
+
{{ if .BlobView.HasRawView }}
47
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
48
+
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
49
+
{{ end }}
50
+
51
+
{{ if .BlobView.ShowToggle }}
52
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
53
+
<a href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .BlobView.ShowingRendered }}" hx-boost="true">
54
+
view {{ if .BlobView.ShowingRendered }}code{{ else }}rendered{{ end }}
55
+
</a>
51
56
{{ end }}
52
57
</div>
53
58
</div>
54
59
</div>
55
-
{{ if and .IsBinary .Unsupported }}
56
-
<p class="text-center text-gray-400 dark:text-gray-500">
57
-
Previews are not supported for this file type.
58
-
</p>
59
-
{{ else if .IsBinary }}
60
-
<div class="text-center">
61
-
{{ if .IsImage }}
62
-
<img src="{{ .ContentSrc }}"
63
-
alt="{{ .Path }}"
64
-
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
65
-
{{ else if .IsVideo }}
66
-
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
67
-
<source src="{{ .ContentSrc }}">
68
-
Your browser does not support the video tag.
69
-
</video>
70
-
{{ end }}
71
-
</div>
72
-
{{ else }}
73
-
<div class="overflow-auto relative">
74
-
{{ if .ShowRendered }}
75
-
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
60
+
{{ if .BlobView.IsUnsupported }}
61
+
<p class="text-center text-gray-400 dark:text-gray-500">
62
+
Previews are not supported for this file type.
63
+
</p>
64
+
{{ else if .BlobView.ContentType.IsSubmodule }}
65
+
<p class="text-center text-gray-400 dark:text-gray-500">
66
+
This directory is a git submodule of <a href="{{ .BlobView.ContentSrc }}">{{ .BlobView.ContentSrc }}</a>.
67
+
</p>
68
+
{{ else if .BlobView.ContentType.IsImage }}
69
+
<div class="text-center">
70
+
<img src="{{ .BlobView.ContentSrc }}"
71
+
alt="{{ .Path }}"
72
+
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
73
+
</div>
74
+
{{ else if .BlobView.ContentType.IsVideo }}
75
+
<div class="text-center">
76
+
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
77
+
<source src="{{ .BlobView.ContentSrc }}">
78
+
Your browser does not support the video tag.
79
+
</video>
80
+
</div>
81
+
{{ else if .BlobView.ContentType.IsSvg }}
82
+
<div class="overflow-auto relative">
83
+
{{ if .BlobView.ShowingRendered }}
84
+
<div class="text-center">
85
+
<img src="{{ .BlobView.ContentSrc }}"
86
+
alt="{{ .Path }}"
87
+
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
88
+
</div>
76
89
{{ else }}
77
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div>
90
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
91
+
{{ end }}
92
+
</div>
93
+
{{ else if .BlobView.ContentType.IsMarkup }}
94
+
<div class="overflow-auto relative">
95
+
{{ if .BlobView.ShowingRendered }}
96
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .BlobView.Contents | readme }}</div>
97
+
{{ else }}
98
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
78
99
{{ end }}
79
-
</div>
100
+
</div>
101
+
{{ else if .BlobView.ContentType.IsCode }}
102
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
80
103
{{ end }}
81
104
{{ template "fragments/multiline-select" }}
82
105
{{ end }}
+8
-1
appview/pages/templates/repo/index.html
+8
-1
appview/pages/templates/repo/index.html
···
35
35
{{ end }}
36
36
37
37
{{ define "repoLanguages" }}
38
-
<details class="group -m-6 mb-4">
38
+
<details class="group -my-4 -m-6 mb-4">
39
39
<summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t">
40
40
{{ range $value := .Languages }}
41
41
<div
···
129
129
{{ $icon := "folder" }}
130
130
{{ $iconStyle := "size-4 fill-current" }}
131
131
132
+
{{ if .IsSubmodule }}
133
+
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
134
+
{{ $icon = "folder-input" }}
135
+
{{ $iconStyle = "size-4" }}
136
+
{{ end }}
137
+
132
138
{{ if .IsFile }}
133
139
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
134
140
{{ $icon = "file" }}
135
141
{{ $iconStyle = "size-4" }}
136
142
{{ end }}
143
+
137
144
<a href="{{ $link }}" class="{{ $linkstyle }}">
138
145
<div class="flex items-center gap-2">
139
146
{{ i $icon $iconStyle "flex-shrink-0" }}
+19
-24
appview/pages/templates/repo/issues/issues.html
+19
-24
appview/pages/templates/repo/issues/issues.html
···
27
27
"Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }}
28
28
{{ $values := list $open $closed }}
29
29
30
-
<div class="flex flex-col gap-2">
31
-
<div class="flex justify-between items-stretch gap-4">
32
-
<form class="flex flex-1 relative" method="GET">
33
-
<input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}">
34
-
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
35
-
{{ i "search" "w-4 h-4" }}
36
-
</div>
37
-
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
38
-
<a
39
-
href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"
40
-
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
41
-
>
42
-
{{ i "x" "w-4 h-4" }}
43
-
</a>
44
-
</form>
45
-
<div class="hidden sm:block">
46
-
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
30
+
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
31
+
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
32
+
<input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}">
33
+
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
34
+
{{ i "search" "w-4 h-4" }}
47
35
</div>
36
+
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
48
37
<a
49
-
href="/{{ .RepoInfo.FullName }}/issues/new"
50
-
class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
51
-
>
52
-
{{ i "circle-plus" "w-4 h-4" }}
53
-
<span>new</span>
38
+
href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"
39
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
40
+
>
41
+
{{ i "x" "w-4 h-4" }}
54
42
</a>
55
-
</div>
56
-
<div class="sm:hidden">
43
+
</form>
44
+
<div class="sm:row-start-1">
57
45
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
58
46
</div>
47
+
<a
48
+
href="/{{ .RepoInfo.FullName }}/issues/new"
49
+
class="col-start-3 btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
50
+
>
51
+
{{ i "circle-plus" "w-4 h-4" }}
52
+
<span>new</span>
53
+
</a>
59
54
</div>
60
55
<div class="error" id="issues"></div>
61
56
{{ end }}
+81
-83
appview/pages/templates/repo/pulls/fragments/pullActions.html
+81
-83
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
22
22
{{ $isLastRound := eq $roundNumber $lastIdx }}
23
23
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
24
24
{{ $isUpToDate := .ResubmitCheck.No }}
25
-
<div class="relative w-fit">
26
-
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
27
-
<button
28
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
29
-
hx-target="#actions-{{$roundNumber}}"
30
-
hx-swap="outerHtml"
31
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
32
-
{{ i "message-square-plus" "w-4 h-4" }}
33
-
<span>comment</span>
34
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
35
-
</button>
36
-
{{ if .BranchDeleteStatus }}
37
-
<button
38
-
hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches"
39
-
hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }'
40
-
hx-swap="none"
41
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
42
-
{{ i "git-branch" "w-4 h-4" }}
43
-
<span>delete branch</span>
44
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
45
-
</button>
46
-
{{ end }}
47
-
{{ if and $isPushAllowed $isOpen $isLastRound }}
48
-
{{ $disabled := "" }}
49
-
{{ if $isConflicted }}
50
-
{{ $disabled = "disabled" }}
51
-
{{ end }}
52
-
<button
53
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
54
-
hx-swap="none"
55
-
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
56
-
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
57
-
{{ i "git-merge" "w-4 h-4" }}
58
-
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
59
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
60
-
</button>
61
-
{{ end }}
25
+
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative">
26
+
<button
27
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
28
+
hx-target="#actions-{{$roundNumber}}"
29
+
hx-swap="outerHtml"
30
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
31
+
{{ i "message-square-plus" "w-4 h-4" }}
32
+
<span>comment</span>
33
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
34
+
</button>
35
+
{{ if .BranchDeleteStatus }}
36
+
<button
37
+
hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches"
38
+
hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }'
39
+
hx-swap="none"
40
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
41
+
{{ i "git-branch" "w-4 h-4" }}
42
+
<span>delete branch</span>
43
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
44
+
</button>
45
+
{{ end }}
46
+
{{ if and $isPushAllowed $isOpen $isLastRound }}
47
+
{{ $disabled := "" }}
48
+
{{ if $isConflicted }}
49
+
{{ $disabled = "disabled" }}
50
+
{{ end }}
51
+
<button
52
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
53
+
hx-swap="none"
54
+
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
55
+
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
56
+
{{ i "git-merge" "w-4 h-4" }}
57
+
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
58
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
59
+
</button>
60
+
{{ end }}
62
61
63
-
{{ if and $isPullAuthor $isOpen $isLastRound }}
64
-
{{ $disabled := "" }}
65
-
{{ if $isUpToDate }}
66
-
{{ $disabled = "disabled" }}
62
+
{{ if and $isPullAuthor $isOpen $isLastRound }}
63
+
{{ $disabled := "" }}
64
+
{{ if $isUpToDate }}
65
+
{{ $disabled = "disabled" }}
66
+
{{ end }}
67
+
<button id="resubmitBtn"
68
+
{{ if not .Pull.IsPatchBased }}
69
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
70
+
{{ else }}
71
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
72
+
hx-target="#actions-{{$roundNumber}}"
73
+
hx-swap="outerHtml"
67
74
{{ end }}
68
-
<button id="resubmitBtn"
69
-
{{ if not .Pull.IsPatchBased }}
70
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
71
-
{{ else }}
72
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
73
-
hx-target="#actions-{{$roundNumber}}"
74
-
hx-swap="outerHtml"
75
-
{{ end }}
76
75
77
-
hx-disabled-elt="#resubmitBtn"
78
-
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
76
+
hx-disabled-elt="#resubmitBtn"
77
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
79
78
80
-
{{ if $disabled }}
81
-
title="Update this branch to resubmit this pull request"
82
-
{{ else }}
83
-
title="Resubmit this pull request"
84
-
{{ end }}
85
-
>
86
-
{{ i "rotate-ccw" "w-4 h-4" }}
87
-
<span>resubmit</span>
88
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
89
-
</button>
90
-
{{ end }}
79
+
{{ if $disabled }}
80
+
title="Update this branch to resubmit this pull request"
81
+
{{ else }}
82
+
title="Resubmit this pull request"
83
+
{{ end }}
84
+
>
85
+
{{ i "rotate-ccw" "w-4 h-4" }}
86
+
<span>resubmit</span>
87
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
88
+
</button>
89
+
{{ end }}
91
90
92
-
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
93
-
<button
94
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
95
-
hx-swap="none"
96
-
class="btn p-2 flex items-center gap-2 group">
97
-
{{ i "ban" "w-4 h-4" }}
98
-
<span>close</span>
99
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
100
-
</button>
101
-
{{ end }}
91
+
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
92
+
<button
93
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
94
+
hx-swap="none"
95
+
class="btn p-2 flex items-center gap-2 group">
96
+
{{ i "ban" "w-4 h-4" }}
97
+
<span>close</span>
98
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
99
+
</button>
100
+
{{ end }}
102
101
103
-
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
104
-
<button
105
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
106
-
hx-swap="none"
107
-
class="btn p-2 flex items-center gap-2 group">
108
-
{{ i "refresh-ccw-dot" "w-4 h-4" }}
109
-
<span>reopen</span>
110
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
111
-
</button>
112
-
{{ end }}
113
-
</div>
102
+
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
103
+
<button
104
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
105
+
hx-swap="none"
106
+
class="btn p-2 flex items-center gap-2 group">
107
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
108
+
<span>reopen</span>
109
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
110
+
</button>
111
+
{{ end }}
114
112
</div>
115
113
{{ end }}
116
114
+19
-23
appview/pages/templates/repo/pulls/pulls.html
+19
-23
appview/pages/templates/repo/pulls/pulls.html
···
33
33
"Icon" "ban"
34
34
"Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }}
35
35
{{ $values := list $open $merged $closed }}
36
-
<div class="flex flex-col gap-2">
37
-
<div class="flex justify-between items-stretch gap-2">
38
-
<form class="flex flex-1 relative" method="GET">
39
-
<input type="hidden" name="state" value="{{ .FilteringBy.String }}">
40
-
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
41
-
{{ i "search" "w-4 h-4" }}
42
-
</div>
43
-
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
44
-
<a
45
-
href="?state={{ .FilteringBy.String }}"
46
-
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
47
-
>
48
-
{{ i "x" "w-4 h-4" }}
49
-
</a>
50
-
</form>
51
-
<div class="hidden sm:block">
52
-
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
36
+
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
37
+
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
38
+
<input type="hidden" name="state" value="{{ .FilteringBy.String }}">
39
+
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
40
+
{{ i "search" "w-4 h-4" }}
53
41
</div>
54
-
<a href="/{{ .RepoInfo.FullName }}/pulls/new"
55
-
class="btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
42
+
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
43
+
<a
44
+
href="?state={{ .FilteringBy.String }}"
45
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
56
46
>
57
-
{{ i "git-pull-request-create" "w-4 h-4" }}
58
-
<span>new</span>
47
+
{{ i "x" "w-4 h-4" }}
59
48
</a>
60
-
</div>
61
-
<div class="sm:hidden">
49
+
</form>
50
+
<div class="sm:row-start-1">
62
51
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
63
52
</div>
53
+
<a
54
+
href="/{{ .RepoInfo.FullName }}/pulls/new"
55
+
class="col-start-3 btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
56
+
>
57
+
{{ i "git-pull-request-create" "w-4 h-4" }}
58
+
<span>new</span>
59
+
</a>
64
60
</div>
65
61
<div class="error" id="pulls"></div>
66
62
{{ end }}
+17
-10
appview/pages/templates/repo/settings/access.html
+17
-10
appview/pages/templates/repo/settings/access.html
···
66
66
<div
67
67
id="add-collaborator-modal"
68
68
popover
69
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
69
+
class="
70
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700
71
+
dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
72
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
70
73
{{ template "addCollaboratorModal" . }}
71
74
</div>
72
75
{{ end }}
···
82
85
ADD COLLABORATOR
83
86
</label>
84
87
<p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p>
85
-
<input
86
-
autocapitalize="none"
87
-
autocorrect="off"
88
-
type="text"
89
-
id="add-collaborator"
90
-
name="collaborator"
91
-
required
92
-
placeholder="foo.bsky.social"
93
-
/>
88
+
<actor-typeahead>
89
+
<input
90
+
autocapitalize="none"
91
+
autocorrect="off"
92
+
autocomplete="off"
93
+
type="text"
94
+
id="add-collaborator"
95
+
name="collaborator"
96
+
required
97
+
placeholder="user.tngl.sh"
98
+
class="w-full"
99
+
/>
100
+
</actor-typeahead>
94
101
<div class="flex gap-2 pt-2">
95
102
<button
96
103
type="button"
+8
appview/pages/templates/repo/tree.html
+8
appview/pages/templates/repo/tree.html
···
59
59
{{ $icon := "folder" }}
60
60
{{ $iconStyle := "size-4 fill-current" }}
61
61
62
+
{{ if .IsSubmodule }}
63
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
64
+
{{ $icon = "folder-input" }}
65
+
{{ $iconStyle = "size-4" }}
66
+
{{ end }}
67
+
62
68
{{ if .IsFile }}
69
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
63
70
{{ $icon = "file" }}
64
71
{{ $iconStyle = "size-4" }}
65
72
{{ end }}
73
+
66
74
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
75
<div class="flex items-center gap-2">
68
76
{{ i $icon $iconStyle "flex-shrink-0" }}
+16
-11
appview/pages/templates/spindles/fragments/addMemberModal.html
+16
-11
appview/pages/templates/spindles/fragments/addMemberModal.html
···
13
13
<div
14
14
id="add-member-{{ .Instance }}"
15
15
popover
16
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
16
+
class="
17
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
18
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
17
19
{{ block "addSpindleMemberPopover" . }} {{ end }}
18
20
</div>
19
21
{{ end }}
···
29
31
ADD MEMBER
30
32
</label>
31
33
<p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p>
32
-
<input
33
-
autocapitalize="none"
34
-
autocorrect="off"
35
-
autocomplete="off"
36
-
type="text"
37
-
id="member-did-{{ .Id }}"
38
-
name="member"
39
-
required
40
-
placeholder="foo.bsky.social"
41
-
/>
34
+
<actor-typeahead>
35
+
<input
36
+
autocapitalize="none"
37
+
autocorrect="off"
38
+
autocomplete="off"
39
+
type="text"
40
+
id="member-did-{{ .Id }}"
41
+
name="member"
42
+
required
43
+
placeholder="user.tngl.sh"
44
+
class="w-full"
45
+
/>
46
+
</actor-typeahead>
42
47
<div class="flex gap-2 pt-2">
43
48
<button
44
49
type="button"
+2
-2
appview/pages/templates/strings/string.html
+2
-2
appview/pages/templates/strings/string.html
···
75
75
</div>
76
76
<div class="overflow-x-auto overflow-y-hidden relative">
77
77
{{ if .ShowRendered }}
78
-
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
78
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div>
79
79
{{ else }}
80
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
80
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .String.Contents .String.Filename | escapeHtml }}</div>
81
81
{{ end }}
82
82
</div>
83
83
{{ template "fragments/multiline-select" }}
+14
appview/pages/templates/user/settings/notifications.html
+14
appview/pages/templates/user/settings/notifications.html
···
144
144
<div class="flex items-center justify-between p-2">
145
145
<div class="flex items-center gap-2">
146
146
<div class="flex flex-col gap-1">
147
+
<span class="font-bold">Mentions</span>
148
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
149
+
<span>When someone mentions you.</span>
150
+
</div>
151
+
</div>
152
+
</div>
153
+
<label class="flex items-center gap-2">
154
+
<input type="checkbox" name="mentioned" {{if .Preferences.UserMentioned}}checked{{end}}>
155
+
</label>
156
+
</div>
157
+
158
+
<div class="flex items-center justify-between p-2">
159
+
<div class="flex items-center gap-2">
160
+
<div class="flex flex-col gap-1">
147
161
<span class="font-bold">Email notifications</span>
148
162
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
149
163
<span>Receive notifications via email in addition to in-app notifications.</span>
+11
-1
appview/pulls/pulls.go
+11
-1
appview/pulls/pulls.go
···
691
691
}
692
692
693
693
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
694
+
l := s.logger.With("handler", "PullComment")
694
695
user := s.oauth.GetUser(r)
695
696
f, err := s.repoResolver.Resolve(r)
696
697
if err != nil {
···
788
789
return
789
790
}
790
791
791
-
s.notifier.NewPullComment(r.Context(), comment)
792
+
rawMentions := markup.FindUserMentions(comment.Body)
793
+
idents := s.idResolver.ResolveIdents(r.Context(), rawMentions)
794
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
795
+
var mentions []syntax.DID
796
+
for _, ident := range idents {
797
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
798
+
mentions = append(mentions, ident.DID)
799
+
}
800
+
}
801
+
s.notifier.NewPullComment(r.Context(), comment, mentions)
792
802
793
803
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
794
804
return
+49
appview/repo/archive.go
+49
appview/repo/archive.go
···
1
+
package repo
2
+
3
+
import (
4
+
"fmt"
5
+
"net/http"
6
+
"net/url"
7
+
"strings"
8
+
9
+
"tangled.org/core/api/tangled"
10
+
xrpcclient "tangled.org/core/appview/xrpcclient"
11
+
12
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
13
+
"github.com/go-chi/chi/v5"
14
+
"github.com/go-git/go-git/v5/plumbing"
15
+
)
16
+
17
+
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
18
+
l := rp.logger.With("handler", "DownloadArchive")
19
+
ref := chi.URLParam(r, "ref")
20
+
ref, _ = url.PathUnescape(ref)
21
+
f, err := rp.repoResolver.Resolve(r)
22
+
if err != nil {
23
+
l.Error("failed to get repo and knot", "err", err)
24
+
return
25
+
}
26
+
scheme := "http"
27
+
if !rp.config.Core.Dev {
28
+
scheme = "https"
29
+
}
30
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
31
+
xrpcc := &indigoxrpc.Client{
32
+
Host: host,
33
+
}
34
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
35
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
36
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
37
+
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
38
+
rp.pages.Error503(w)
39
+
return
40
+
}
41
+
// Set headers for file download, just pass along whatever the knot specifies
42
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
43
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
44
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
45
+
w.Header().Set("Content-Type", "application/gzip")
46
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
47
+
// Write the archive data directly
48
+
w.Write(archiveBytes)
49
+
}
+291
appview/repo/blob.go
+291
appview/repo/blob.go
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/base64"
5
+
"fmt"
6
+
"io"
7
+
"net/http"
8
+
"net/url"
9
+
"path/filepath"
10
+
"slices"
11
+
"strings"
12
+
13
+
"tangled.org/core/api/tangled"
14
+
"tangled.org/core/appview/config"
15
+
"tangled.org/core/appview/models"
16
+
"tangled.org/core/appview/pages"
17
+
"tangled.org/core/appview/pages/markup"
18
+
"tangled.org/core/appview/reporesolver"
19
+
xrpcclient "tangled.org/core/appview/xrpcclient"
20
+
21
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
22
+
"github.com/go-chi/chi/v5"
23
+
)
24
+
25
+
// the content can be one of the following:
26
+
//
27
+
// - code : text | | raw
28
+
// - markup : text | rendered | raw
29
+
// - svg : text | rendered | raw
30
+
// - png : | rendered | raw
31
+
// - video : | rendered | raw
32
+
// - submodule : | rendered |
33
+
// - rest : | |
34
+
func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) {
35
+
l := rp.logger.With("handler", "RepoBlob")
36
+
37
+
f, err := rp.repoResolver.Resolve(r)
38
+
if err != nil {
39
+
l.Error("failed to get repo and knot", "err", err)
40
+
return
41
+
}
42
+
43
+
ref := chi.URLParam(r, "ref")
44
+
ref, _ = url.PathUnescape(ref)
45
+
46
+
filePath := chi.URLParam(r, "*")
47
+
filePath, _ = url.PathUnescape(filePath)
48
+
49
+
scheme := "http"
50
+
if !rp.config.Core.Dev {
51
+
scheme = "https"
52
+
}
53
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
54
+
xrpcc := &indigoxrpc.Client{
55
+
Host: host,
56
+
}
57
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
58
+
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
59
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
60
+
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
61
+
rp.pages.Error503(w)
62
+
return
63
+
}
64
+
65
+
// Use XRPC response directly instead of converting to internal types
66
+
var breadcrumbs [][]string
67
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
68
+
if filePath != "" {
69
+
for idx, elem := range strings.Split(filePath, "/") {
70
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
71
+
}
72
+
}
73
+
74
+
// Create the blob view
75
+
blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query())
76
+
77
+
user := rp.oauth.GetUser(r)
78
+
79
+
rp.pages.RepoBlob(w, pages.RepoBlobParams{
80
+
LoggedInUser: user,
81
+
RepoInfo: f.RepoInfo(user),
82
+
BreadCrumbs: breadcrumbs,
83
+
BlobView: blobView,
84
+
RepoBlob_Output: resp,
85
+
})
86
+
}
87
+
88
+
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
89
+
l := rp.logger.With("handler", "RepoBlobRaw")
90
+
91
+
f, err := rp.repoResolver.Resolve(r)
92
+
if err != nil {
93
+
l.Error("failed to get repo and knot", "err", err)
94
+
w.WriteHeader(http.StatusBadRequest)
95
+
return
96
+
}
97
+
98
+
ref := chi.URLParam(r, "ref")
99
+
ref, _ = url.PathUnescape(ref)
100
+
101
+
filePath := chi.URLParam(r, "*")
102
+
filePath, _ = url.PathUnescape(filePath)
103
+
104
+
scheme := "http"
105
+
if !rp.config.Core.Dev {
106
+
scheme = "https"
107
+
}
108
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
109
+
baseURL := &url.URL{
110
+
Scheme: scheme,
111
+
Host: f.Knot,
112
+
Path: "/xrpc/sh.tangled.repo.blob",
113
+
}
114
+
query := baseURL.Query()
115
+
query.Set("repo", repo)
116
+
query.Set("ref", ref)
117
+
query.Set("path", filePath)
118
+
query.Set("raw", "true")
119
+
baseURL.RawQuery = query.Encode()
120
+
blobURL := baseURL.String()
121
+
req, err := http.NewRequest("GET", blobURL, nil)
122
+
if err != nil {
123
+
l.Error("failed to create request", "err", err)
124
+
return
125
+
}
126
+
127
+
// forward the If-None-Match header
128
+
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
129
+
req.Header.Set("If-None-Match", clientETag)
130
+
}
131
+
client := &http.Client{}
132
+
133
+
resp, err := client.Do(req)
134
+
if err != nil {
135
+
l.Error("failed to reach knotserver", "err", err)
136
+
rp.pages.Error503(w)
137
+
return
138
+
}
139
+
140
+
defer resp.Body.Close()
141
+
142
+
// forward 304 not modified
143
+
if resp.StatusCode == http.StatusNotModified {
144
+
w.WriteHeader(http.StatusNotModified)
145
+
return
146
+
}
147
+
148
+
if resp.StatusCode != http.StatusOK {
149
+
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
150
+
w.WriteHeader(resp.StatusCode)
151
+
_, _ = io.Copy(w, resp.Body)
152
+
return
153
+
}
154
+
155
+
contentType := resp.Header.Get("Content-Type")
156
+
body, err := io.ReadAll(resp.Body)
157
+
if err != nil {
158
+
l.Error("error reading response body from knotserver", "err", err)
159
+
w.WriteHeader(http.StatusInternalServerError)
160
+
return
161
+
}
162
+
163
+
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
164
+
// serve all textual content as text/plain
165
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
166
+
w.Write(body)
167
+
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
168
+
// serve images and videos with their original content type
169
+
w.Header().Set("Content-Type", contentType)
170
+
w.Write(body)
171
+
} else {
172
+
w.WriteHeader(http.StatusUnsupportedMediaType)
173
+
w.Write([]byte("unsupported content type"))
174
+
return
175
+
}
176
+
}
177
+
178
+
// NewBlobView creates a BlobView from the XRPC response
179
+
func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView {
180
+
view := models.BlobView{
181
+
Contents: "",
182
+
Lines: 0,
183
+
}
184
+
185
+
// Set size
186
+
if resp.Size != nil {
187
+
view.SizeHint = uint64(*resp.Size)
188
+
} else if resp.Content != nil {
189
+
view.SizeHint = uint64(len(*resp.Content))
190
+
}
191
+
192
+
if resp.Submodule != nil {
193
+
view.ContentType = models.BlobContentTypeSubmodule
194
+
view.HasRenderedView = true
195
+
view.ContentSrc = resp.Submodule.Url
196
+
return view
197
+
}
198
+
199
+
// Determine if binary
200
+
if resp.IsBinary != nil && *resp.IsBinary {
201
+
view.ContentSrc = generateBlobURL(config, f, ref, filePath)
202
+
ext := strings.ToLower(filepath.Ext(resp.Path))
203
+
204
+
switch ext {
205
+
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
206
+
view.ContentType = models.BlobContentTypeImage
207
+
view.HasRawView = true
208
+
view.HasRenderedView = true
209
+
view.ShowingRendered = true
210
+
211
+
case ".svg":
212
+
view.ContentType = models.BlobContentTypeSvg
213
+
view.HasRawView = true
214
+
view.HasTextView = true
215
+
view.HasRenderedView = true
216
+
view.ShowingRendered = queryParams.Get("code") != "true"
217
+
if resp.Content != nil {
218
+
bytes, _ := base64.StdEncoding.DecodeString(*resp.Content)
219
+
view.Contents = string(bytes)
220
+
view.Lines = strings.Count(view.Contents, "\n") + 1
221
+
}
222
+
223
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
224
+
view.ContentType = models.BlobContentTypeVideo
225
+
view.HasRawView = true
226
+
view.HasRenderedView = true
227
+
view.ShowingRendered = true
228
+
}
229
+
230
+
return view
231
+
}
232
+
233
+
// otherwise, we are dealing with text content
234
+
view.HasRawView = true
235
+
view.HasTextView = true
236
+
237
+
if resp.Content != nil {
238
+
view.Contents = *resp.Content
239
+
view.Lines = strings.Count(view.Contents, "\n") + 1
240
+
}
241
+
242
+
// with text, we may be dealing with markdown
243
+
format := markup.GetFormat(resp.Path)
244
+
if format == markup.FormatMarkdown {
245
+
view.ContentType = models.BlobContentTypeMarkup
246
+
view.HasRenderedView = true
247
+
view.ShowingRendered = queryParams.Get("code") != "true"
248
+
}
249
+
250
+
return view
251
+
}
252
+
253
+
func generateBlobURL(config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string) string {
254
+
scheme := "http"
255
+
if !config.Core.Dev {
256
+
scheme = "https"
257
+
}
258
+
259
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
260
+
baseURL := &url.URL{
261
+
Scheme: scheme,
262
+
Host: f.Knot,
263
+
Path: "/xrpc/sh.tangled.repo.blob",
264
+
}
265
+
query := baseURL.Query()
266
+
query.Set("repo", repoName)
267
+
query.Set("ref", ref)
268
+
query.Set("path", filePath)
269
+
query.Set("raw", "true")
270
+
baseURL.RawQuery = query.Encode()
271
+
blobURL := baseURL.String()
272
+
273
+
if !config.Core.Dev {
274
+
return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL)
275
+
}
276
+
return blobURL
277
+
}
278
+
279
+
func isTextualMimeType(mimeType string) bool {
280
+
textualTypes := []string{
281
+
"application/json",
282
+
"application/xml",
283
+
"application/yaml",
284
+
"application/x-yaml",
285
+
"application/toml",
286
+
"application/javascript",
287
+
"application/ecmascript",
288
+
"message/",
289
+
}
290
+
return slices.Contains(textualTypes, mimeType)
291
+
}
+95
appview/repo/branches.go
+95
appview/repo/branches.go
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
8
+
"tangled.org/core/api/tangled"
9
+
"tangled.org/core/appview/oauth"
10
+
"tangled.org/core/appview/pages"
11
+
xrpcclient "tangled.org/core/appview/xrpcclient"
12
+
"tangled.org/core/types"
13
+
14
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
15
+
)
16
+
17
+
func (rp *Repo) Branches(w http.ResponseWriter, r *http.Request) {
18
+
l := rp.logger.With("handler", "RepoBranches")
19
+
f, err := rp.repoResolver.Resolve(r)
20
+
if err != nil {
21
+
l.Error("failed to get repo and knot", "err", err)
22
+
return
23
+
}
24
+
scheme := "http"
25
+
if !rp.config.Core.Dev {
26
+
scheme = "https"
27
+
}
28
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
29
+
xrpcc := &indigoxrpc.Client{
30
+
Host: host,
31
+
}
32
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
33
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
34
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
35
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
36
+
rp.pages.Error503(w)
37
+
return
38
+
}
39
+
var result types.RepoBranchesResponse
40
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
41
+
l.Error("failed to decode XRPC response", "err", err)
42
+
rp.pages.Error503(w)
43
+
return
44
+
}
45
+
sortBranches(result.Branches)
46
+
user := rp.oauth.GetUser(r)
47
+
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
48
+
LoggedInUser: user,
49
+
RepoInfo: f.RepoInfo(user),
50
+
RepoBranchesResponse: result,
51
+
})
52
+
}
53
+
54
+
func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) {
55
+
l := rp.logger.With("handler", "DeleteBranch")
56
+
f, err := rp.repoResolver.Resolve(r)
57
+
if err != nil {
58
+
l.Error("failed to get repo and knot", "err", err)
59
+
return
60
+
}
61
+
noticeId := "delete-branch-error"
62
+
fail := func(msg string, err error) {
63
+
l.Error(msg, "err", err)
64
+
rp.pages.Notice(w, noticeId, msg)
65
+
}
66
+
branch := r.FormValue("branch")
67
+
if branch == "" {
68
+
fail("No branch provided.", nil)
69
+
return
70
+
}
71
+
client, err := rp.oauth.ServiceClient(
72
+
r,
73
+
oauth.WithService(f.Knot),
74
+
oauth.WithLxm(tangled.RepoDeleteBranchNSID),
75
+
oauth.WithDev(rp.config.Core.Dev),
76
+
)
77
+
if err != nil {
78
+
fail("Failed to connect to knotserver", nil)
79
+
return
80
+
}
81
+
err = tangled.RepoDeleteBranch(
82
+
r.Context(),
83
+
client,
84
+
&tangled.RepoDeleteBranch_Input{
85
+
Branch: branch,
86
+
Repo: f.RepoAt().String(),
87
+
},
88
+
)
89
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
90
+
fail(fmt.Sprintf("Failed to delete branch: %s", err), err)
91
+
return
92
+
}
93
+
l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt())
94
+
rp.pages.HxRefresh(w)
95
+
}
+214
appview/repo/compare.go
+214
appview/repo/compare.go
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"net/url"
8
+
"strings"
9
+
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/pages"
12
+
xrpcclient "tangled.org/core/appview/xrpcclient"
13
+
"tangled.org/core/patchutil"
14
+
"tangled.org/core/types"
15
+
16
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
17
+
"github.com/go-chi/chi/v5"
18
+
)
19
+
20
+
func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) {
21
+
l := rp.logger.With("handler", "RepoCompareNew")
22
+
23
+
user := rp.oauth.GetUser(r)
24
+
f, err := rp.repoResolver.Resolve(r)
25
+
if err != nil {
26
+
l.Error("failed to get repo and knot", "err", err)
27
+
return
28
+
}
29
+
30
+
scheme := "http"
31
+
if !rp.config.Core.Dev {
32
+
scheme = "https"
33
+
}
34
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
35
+
xrpcc := &indigoxrpc.Client{
36
+
Host: host,
37
+
}
38
+
39
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
40
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
41
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
42
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
43
+
rp.pages.Error503(w)
44
+
return
45
+
}
46
+
47
+
var branchResult types.RepoBranchesResponse
48
+
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
49
+
l.Error("failed to decode XRPC branches response", "err", err)
50
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
51
+
return
52
+
}
53
+
branches := branchResult.Branches
54
+
55
+
sortBranches(branches)
56
+
57
+
var defaultBranch string
58
+
for _, b := range branches {
59
+
if b.IsDefault {
60
+
defaultBranch = b.Name
61
+
}
62
+
}
63
+
64
+
base := defaultBranch
65
+
head := defaultBranch
66
+
67
+
params := r.URL.Query()
68
+
queryBase := params.Get("base")
69
+
queryHead := params.Get("head")
70
+
if queryBase != "" {
71
+
base = queryBase
72
+
}
73
+
if queryHead != "" {
74
+
head = queryHead
75
+
}
76
+
77
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
78
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
79
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
80
+
rp.pages.Error503(w)
81
+
return
82
+
}
83
+
84
+
var tags types.RepoTagsResponse
85
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
86
+
l.Error("failed to decode XRPC tags response", "err", err)
87
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
88
+
return
89
+
}
90
+
91
+
repoinfo := f.RepoInfo(user)
92
+
93
+
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
94
+
LoggedInUser: user,
95
+
RepoInfo: repoinfo,
96
+
Branches: branches,
97
+
Tags: tags.Tags,
98
+
Base: base,
99
+
Head: head,
100
+
})
101
+
}
102
+
103
+
func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) {
104
+
l := rp.logger.With("handler", "RepoCompare")
105
+
106
+
user := rp.oauth.GetUser(r)
107
+
f, err := rp.repoResolver.Resolve(r)
108
+
if err != nil {
109
+
l.Error("failed to get repo and knot", "err", err)
110
+
return
111
+
}
112
+
113
+
var diffOpts types.DiffOpts
114
+
if d := r.URL.Query().Get("diff"); d == "split" {
115
+
diffOpts.Split = true
116
+
}
117
+
118
+
// if user is navigating to one of
119
+
// /compare/{base}/{head}
120
+
// /compare/{base}...{head}
121
+
base := chi.URLParam(r, "base")
122
+
head := chi.URLParam(r, "head")
123
+
if base == "" && head == "" {
124
+
rest := chi.URLParam(r, "*") // master...feature/xyz
125
+
parts := strings.SplitN(rest, "...", 2)
126
+
if len(parts) == 2 {
127
+
base = parts[0]
128
+
head = parts[1]
129
+
}
130
+
}
131
+
132
+
base, _ = url.PathUnescape(base)
133
+
head, _ = url.PathUnescape(head)
134
+
135
+
if base == "" || head == "" {
136
+
l.Error("invalid comparison")
137
+
rp.pages.Error404(w)
138
+
return
139
+
}
140
+
141
+
scheme := "http"
142
+
if !rp.config.Core.Dev {
143
+
scheme = "https"
144
+
}
145
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
146
+
xrpcc := &indigoxrpc.Client{
147
+
Host: host,
148
+
}
149
+
150
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
151
+
152
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
153
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
154
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
155
+
rp.pages.Error503(w)
156
+
return
157
+
}
158
+
159
+
var branches types.RepoBranchesResponse
160
+
if err := json.Unmarshal(branchBytes, &branches); err != nil {
161
+
l.Error("failed to decode XRPC branches response", "err", err)
162
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
163
+
return
164
+
}
165
+
166
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
167
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
168
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
169
+
rp.pages.Error503(w)
170
+
return
171
+
}
172
+
173
+
var tags types.RepoTagsResponse
174
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
175
+
l.Error("failed to decode XRPC tags response", "err", err)
176
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
177
+
return
178
+
}
179
+
180
+
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
181
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
182
+
l.Error("failed to call XRPC repo.compare", "err", xrpcerr)
183
+
rp.pages.Error503(w)
184
+
return
185
+
}
186
+
187
+
var formatPatch types.RepoFormatPatchResponse
188
+
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
189
+
l.Error("failed to decode XRPC compare response", "err", err)
190
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
191
+
return
192
+
}
193
+
194
+
var diff types.NiceDiff
195
+
if formatPatch.CombinedPatchRaw != "" {
196
+
diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base)
197
+
} else {
198
+
diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
199
+
}
200
+
201
+
repoinfo := f.RepoInfo(user)
202
+
203
+
rp.pages.RepoCompare(w, pages.RepoCompareParams{
204
+
LoggedInUser: user,
205
+
RepoInfo: repoinfo,
206
+
Branches: branches.Branches,
207
+
Tags: tags.Tags,
208
+
Base: base,
209
+
Head: head,
210
+
Diff: &diff,
211
+
DiffOpts: diffOpts,
212
+
})
213
+
214
+
}
+1
-1
appview/repo/feed.go
+1
-1
appview/repo/feed.go
···
146
146
return fmt.Sprintf("%s in %s", base, repoName)
147
147
}
148
148
149
-
func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) {
149
+
func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) {
150
150
f, err := rp.repoResolver.Resolve(r)
151
151
if err != nil {
152
152
log.Println("failed to fully resolve repo:", err)
+5
-6
appview/repo/index.go
+5
-6
appview/repo/index.go
···
30
30
"github.com/go-enry/go-enry/v2"
31
31
)
32
32
33
-
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
33
+
func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) {
34
34
l := rp.logger.With("handler", "RepoIndex")
35
35
36
36
ref := chi.URLParam(r, "ref")
···
351
351
if treeResp != nil && treeResp.Files != nil {
352
352
for _, file := range treeResp.Files {
353
353
niceFile := types.NiceTree{
354
-
IsFile: file.Is_file,
355
-
IsSubtree: file.Is_subtree,
356
-
Name: file.Name,
357
-
Mode: file.Mode,
358
-
Size: file.Size,
354
+
Name: file.Name,
355
+
Mode: file.Mode,
356
+
Size: file.Size,
359
357
}
358
+
360
359
if file.Last_commit != nil {
361
360
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
362
361
niceFile.LastCommit = &types.LastCommitInfo{
+223
appview/repo/log.go
+223
appview/repo/log.go
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"net/url"
8
+
"strconv"
9
+
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/commitverify"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pages"
15
+
xrpcclient "tangled.org/core/appview/xrpcclient"
16
+
"tangled.org/core/types"
17
+
18
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
19
+
"github.com/go-chi/chi/v5"
20
+
"github.com/go-git/go-git/v5/plumbing"
21
+
)
22
+
23
+
func (rp *Repo) Log(w http.ResponseWriter, r *http.Request) {
24
+
l := rp.logger.With("handler", "RepoLog")
25
+
26
+
f, err := rp.repoResolver.Resolve(r)
27
+
if err != nil {
28
+
l.Error("failed to fully resolve repo", "err", err)
29
+
return
30
+
}
31
+
32
+
page := 1
33
+
if r.URL.Query().Get("page") != "" {
34
+
page, err = strconv.Atoi(r.URL.Query().Get("page"))
35
+
if err != nil {
36
+
page = 1
37
+
}
38
+
}
39
+
40
+
ref := chi.URLParam(r, "ref")
41
+
ref, _ = url.PathUnescape(ref)
42
+
43
+
scheme := "http"
44
+
if !rp.config.Core.Dev {
45
+
scheme = "https"
46
+
}
47
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
48
+
xrpcc := &indigoxrpc.Client{
49
+
Host: host,
50
+
}
51
+
52
+
limit := int64(60)
53
+
cursor := ""
54
+
if page > 1 {
55
+
// Convert page number to cursor (offset)
56
+
offset := (page - 1) * int(limit)
57
+
cursor = strconv.Itoa(offset)
58
+
}
59
+
60
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
61
+
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
62
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
63
+
l.Error("failed to call XRPC repo.log", "err", xrpcerr)
64
+
rp.pages.Error503(w)
65
+
return
66
+
}
67
+
68
+
var xrpcResp types.RepoLogResponse
69
+
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
70
+
l.Error("failed to decode XRPC response", "err", err)
71
+
rp.pages.Error503(w)
72
+
return
73
+
}
74
+
75
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
76
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
77
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
78
+
rp.pages.Error503(w)
79
+
return
80
+
}
81
+
82
+
tagMap := make(map[string][]string)
83
+
if tagBytes != nil {
84
+
var tagResp types.RepoTagsResponse
85
+
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
86
+
for _, tag := range tagResp.Tags {
87
+
hash := tag.Hash
88
+
if tag.Tag != nil {
89
+
hash = tag.Tag.Target.String()
90
+
}
91
+
tagMap[hash] = append(tagMap[hash], tag.Name)
92
+
}
93
+
}
94
+
}
95
+
96
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
97
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
98
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
99
+
rp.pages.Error503(w)
100
+
return
101
+
}
102
+
103
+
if branchBytes != nil {
104
+
var branchResp types.RepoBranchesResponse
105
+
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
106
+
for _, branch := range branchResp.Branches {
107
+
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
108
+
}
109
+
}
110
+
}
111
+
112
+
user := rp.oauth.GetUser(r)
113
+
114
+
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
115
+
if err != nil {
116
+
l.Error("failed to fetch email to did mapping", "err", err)
117
+
}
118
+
119
+
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
120
+
if err != nil {
121
+
l.Error("failed to GetVerifiedObjectCommits", "err", err)
122
+
}
123
+
124
+
repoInfo := f.RepoInfo(user)
125
+
126
+
var shas []string
127
+
for _, c := range xrpcResp.Commits {
128
+
shas = append(shas, c.Hash.String())
129
+
}
130
+
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
131
+
if err != nil {
132
+
l.Error("failed to getPipelineStatuses", "err", err)
133
+
// non-fatal
134
+
}
135
+
136
+
rp.pages.RepoLog(w, pages.RepoLogParams{
137
+
LoggedInUser: user,
138
+
TagMap: tagMap,
139
+
RepoInfo: repoInfo,
140
+
RepoLogResponse: xrpcResp,
141
+
EmailToDid: emailToDidMap,
142
+
VerifiedCommits: vc,
143
+
Pipelines: pipelines,
144
+
})
145
+
}
146
+
147
+
func (rp *Repo) Commit(w http.ResponseWriter, r *http.Request) {
148
+
l := rp.logger.With("handler", "RepoCommit")
149
+
150
+
f, err := rp.repoResolver.Resolve(r)
151
+
if err != nil {
152
+
l.Error("failed to fully resolve repo", "err", err)
153
+
return
154
+
}
155
+
ref := chi.URLParam(r, "ref")
156
+
ref, _ = url.PathUnescape(ref)
157
+
158
+
var diffOpts types.DiffOpts
159
+
if d := r.URL.Query().Get("diff"); d == "split" {
160
+
diffOpts.Split = true
161
+
}
162
+
163
+
if !plumbing.IsHash(ref) {
164
+
rp.pages.Error404(w)
165
+
return
166
+
}
167
+
168
+
scheme := "http"
169
+
if !rp.config.Core.Dev {
170
+
scheme = "https"
171
+
}
172
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
173
+
xrpcc := &indigoxrpc.Client{
174
+
Host: host,
175
+
}
176
+
177
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
178
+
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
179
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
180
+
l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
181
+
rp.pages.Error503(w)
182
+
return
183
+
}
184
+
185
+
var result types.RepoCommitResponse
186
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
187
+
l.Error("failed to decode XRPC response", "err", err)
188
+
rp.pages.Error503(w)
189
+
return
190
+
}
191
+
192
+
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
193
+
if err != nil {
194
+
l.Error("failed to get email to did mapping", "err", err)
195
+
}
196
+
197
+
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
198
+
if err != nil {
199
+
l.Error("failed to GetVerifiedCommits", "err", err)
200
+
}
201
+
202
+
user := rp.oauth.GetUser(r)
203
+
repoInfo := f.RepoInfo(user)
204
+
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
205
+
if err != nil {
206
+
l.Error("failed to getPipelineStatuses", "err", err)
207
+
// non-fatal
208
+
}
209
+
var pipeline *models.Pipeline
210
+
if p, ok := pipelines[result.Diff.Commit.This]; ok {
211
+
pipeline = &p
212
+
}
213
+
214
+
rp.pages.RepoCommit(w, pages.RepoCommitParams{
215
+
LoggedInUser: user,
216
+
RepoInfo: f.RepoInfo(user),
217
+
RepoCommitResponse: result,
218
+
EmailToDid: emailToDidMap,
219
+
VerifiedCommit: vc,
220
+
Pipeline: pipeline,
221
+
DiffOpts: diffOpts,
222
+
})
223
+
}
+1
-1
appview/repo/opengraph.go
+1
-1
appview/repo/opengraph.go
···
327
327
return nil
328
328
}
329
329
330
-
func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
330
+
func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) {
331
331
f, err := rp.repoResolver.Resolve(r)
332
332
if err != nil {
333
333
log.Println("failed to get repo and knot", err)
-1370
appview/repo/repo.go
-1370
appview/repo/repo.go
···
3
3
import (
4
4
"context"
5
5
"database/sql"
6
-
"encoding/json"
7
6
"errors"
8
7
"fmt"
9
-
"io"
10
8
"log/slog"
11
9
"net/http"
12
10
"net/url"
13
-
"path/filepath"
14
11
"slices"
15
-
"strconv"
16
12
"strings"
17
13
"time"
18
14
19
15
"tangled.org/core/api/tangled"
20
-
"tangled.org/core/appview/commitverify"
21
16
"tangled.org/core/appview/config"
22
17
"tangled.org/core/appview/db"
23
18
"tangled.org/core/appview/models"
24
19
"tangled.org/core/appview/notify"
25
20
"tangled.org/core/appview/oauth"
26
21
"tangled.org/core/appview/pages"
27
-
"tangled.org/core/appview/pages/markup"
28
22
"tangled.org/core/appview/reporesolver"
29
23
"tangled.org/core/appview/validator"
30
24
xrpcclient "tangled.org/core/appview/xrpcclient"
31
25
"tangled.org/core/eventconsumer"
32
26
"tangled.org/core/idresolver"
33
-
"tangled.org/core/patchutil"
34
27
"tangled.org/core/rbac"
35
28
"tangled.org/core/tid"
36
-
"tangled.org/core/types"
37
29
"tangled.org/core/xrpc/serviceauth"
38
30
39
31
comatproto "github.com/bluesky-social/indigo/api/atproto"
40
32
atpclient "github.com/bluesky-social/indigo/atproto/client"
41
33
"github.com/bluesky-social/indigo/atproto/syntax"
42
34
lexutil "github.com/bluesky-social/indigo/lex/util"
43
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
44
35
securejoin "github.com/cyphar/filepath-securejoin"
45
36
"github.com/go-chi/chi/v5"
46
-
"github.com/go-git/go-git/v5/plumbing"
47
37
)
48
38
49
39
type Repo struct {
···
88
78
}
89
79
}
90
80
91
-
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
92
-
l := rp.logger.With("handler", "DownloadArchive")
93
-
94
-
ref := chi.URLParam(r, "ref")
95
-
ref, _ = url.PathUnescape(ref)
96
-
97
-
f, err := rp.repoResolver.Resolve(r)
98
-
if err != nil {
99
-
l.Error("failed to get repo and knot", "err", err)
100
-
return
101
-
}
102
-
103
-
scheme := "http"
104
-
if !rp.config.Core.Dev {
105
-
scheme = "https"
106
-
}
107
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
108
-
xrpcc := &indigoxrpc.Client{
109
-
Host: host,
110
-
}
111
-
112
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
113
-
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
114
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
115
-
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
116
-
rp.pages.Error503(w)
117
-
return
118
-
}
119
-
120
-
// Set headers for file download, just pass along whatever the knot specifies
121
-
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
122
-
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
123
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
124
-
w.Header().Set("Content-Type", "application/gzip")
125
-
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
126
-
127
-
// Write the archive data directly
128
-
w.Write(archiveBytes)
129
-
}
130
-
131
-
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
132
-
l := rp.logger.With("handler", "RepoLog")
133
-
134
-
f, err := rp.repoResolver.Resolve(r)
135
-
if err != nil {
136
-
l.Error("failed to fully resolve repo", "err", err)
137
-
return
138
-
}
139
-
140
-
page := 1
141
-
if r.URL.Query().Get("page") != "" {
142
-
page, err = strconv.Atoi(r.URL.Query().Get("page"))
143
-
if err != nil {
144
-
page = 1
145
-
}
146
-
}
147
-
148
-
ref := chi.URLParam(r, "ref")
149
-
ref, _ = url.PathUnescape(ref)
150
-
151
-
scheme := "http"
152
-
if !rp.config.Core.Dev {
153
-
scheme = "https"
154
-
}
155
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
156
-
xrpcc := &indigoxrpc.Client{
157
-
Host: host,
158
-
}
159
-
160
-
limit := int64(60)
161
-
cursor := ""
162
-
if page > 1 {
163
-
// Convert page number to cursor (offset)
164
-
offset := (page - 1) * int(limit)
165
-
cursor = strconv.Itoa(offset)
166
-
}
167
-
168
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
169
-
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
170
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
171
-
l.Error("failed to call XRPC repo.log", "err", xrpcerr)
172
-
rp.pages.Error503(w)
173
-
return
174
-
}
175
-
176
-
var xrpcResp types.RepoLogResponse
177
-
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
178
-
l.Error("failed to decode XRPC response", "err", err)
179
-
rp.pages.Error503(w)
180
-
return
181
-
}
182
-
183
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
184
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
185
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
186
-
rp.pages.Error503(w)
187
-
return
188
-
}
189
-
190
-
tagMap := make(map[string][]string)
191
-
if tagBytes != nil {
192
-
var tagResp types.RepoTagsResponse
193
-
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
194
-
for _, tag := range tagResp.Tags {
195
-
hash := tag.Hash
196
-
if tag.Tag != nil {
197
-
hash = tag.Tag.Target.String()
198
-
}
199
-
tagMap[hash] = append(tagMap[hash], tag.Name)
200
-
}
201
-
}
202
-
}
203
-
204
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
205
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
206
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
207
-
rp.pages.Error503(w)
208
-
return
209
-
}
210
-
211
-
if branchBytes != nil {
212
-
var branchResp types.RepoBranchesResponse
213
-
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
214
-
for _, branch := range branchResp.Branches {
215
-
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
216
-
}
217
-
}
218
-
}
219
-
220
-
user := rp.oauth.GetUser(r)
221
-
222
-
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
223
-
if err != nil {
224
-
l.Error("failed to fetch email to did mapping", "err", err)
225
-
}
226
-
227
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
228
-
if err != nil {
229
-
l.Error("failed to GetVerifiedObjectCommits", "err", err)
230
-
}
231
-
232
-
repoInfo := f.RepoInfo(user)
233
-
234
-
var shas []string
235
-
for _, c := range xrpcResp.Commits {
236
-
shas = append(shas, c.Hash.String())
237
-
}
238
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
239
-
if err != nil {
240
-
l.Error("failed to getPipelineStatuses", "err", err)
241
-
// non-fatal
242
-
}
243
-
244
-
rp.pages.RepoLog(w, pages.RepoLogParams{
245
-
LoggedInUser: user,
246
-
TagMap: tagMap,
247
-
RepoInfo: repoInfo,
248
-
RepoLogResponse: xrpcResp,
249
-
EmailToDid: emailToDidMap,
250
-
VerifiedCommits: vc,
251
-
Pipelines: pipelines,
252
-
})
253
-
}
254
-
255
-
func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
256
-
l := rp.logger.With("handler", "RepoCommit")
257
-
258
-
f, err := rp.repoResolver.Resolve(r)
259
-
if err != nil {
260
-
l.Error("failed to fully resolve repo", "err", err)
261
-
return
262
-
}
263
-
ref := chi.URLParam(r, "ref")
264
-
ref, _ = url.PathUnescape(ref)
265
-
266
-
var diffOpts types.DiffOpts
267
-
if d := r.URL.Query().Get("diff"); d == "split" {
268
-
diffOpts.Split = true
269
-
}
270
-
271
-
if !plumbing.IsHash(ref) {
272
-
rp.pages.Error404(w)
273
-
return
274
-
}
275
-
276
-
scheme := "http"
277
-
if !rp.config.Core.Dev {
278
-
scheme = "https"
279
-
}
280
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
281
-
xrpcc := &indigoxrpc.Client{
282
-
Host: host,
283
-
}
284
-
285
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
286
-
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
287
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
288
-
l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
289
-
rp.pages.Error503(w)
290
-
return
291
-
}
292
-
293
-
var result types.RepoCommitResponse
294
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
295
-
l.Error("failed to decode XRPC response", "err", err)
296
-
rp.pages.Error503(w)
297
-
return
298
-
}
299
-
300
-
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
301
-
if err != nil {
302
-
l.Error("failed to get email to did mapping", "err", err)
303
-
}
304
-
305
-
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
306
-
if err != nil {
307
-
l.Error("failed to GetVerifiedCommits", "err", err)
308
-
}
309
-
310
-
user := rp.oauth.GetUser(r)
311
-
repoInfo := f.RepoInfo(user)
312
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
313
-
if err != nil {
314
-
l.Error("failed to getPipelineStatuses", "err", err)
315
-
// non-fatal
316
-
}
317
-
var pipeline *models.Pipeline
318
-
if p, ok := pipelines[result.Diff.Commit.This]; ok {
319
-
pipeline = &p
320
-
}
321
-
322
-
rp.pages.RepoCommit(w, pages.RepoCommitParams{
323
-
LoggedInUser: user,
324
-
RepoInfo: f.RepoInfo(user),
325
-
RepoCommitResponse: result,
326
-
EmailToDid: emailToDidMap,
327
-
VerifiedCommit: vc,
328
-
Pipeline: pipeline,
329
-
DiffOpts: diffOpts,
330
-
})
331
-
}
332
-
333
-
func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
334
-
l := rp.logger.With("handler", "RepoTree")
335
-
336
-
f, err := rp.repoResolver.Resolve(r)
337
-
if err != nil {
338
-
l.Error("failed to fully resolve repo", "err", err)
339
-
return
340
-
}
341
-
342
-
ref := chi.URLParam(r, "ref")
343
-
ref, _ = url.PathUnescape(ref)
344
-
345
-
// if the tree path has a trailing slash, let's strip it
346
-
// so we don't 404
347
-
treePath := chi.URLParam(r, "*")
348
-
treePath, _ = url.PathUnescape(treePath)
349
-
treePath = strings.TrimSuffix(treePath, "/")
350
-
351
-
scheme := "http"
352
-
if !rp.config.Core.Dev {
353
-
scheme = "https"
354
-
}
355
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
356
-
xrpcc := &indigoxrpc.Client{
357
-
Host: host,
358
-
}
359
-
360
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
361
-
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
362
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
363
-
l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
364
-
rp.pages.Error503(w)
365
-
return
366
-
}
367
-
368
-
// Convert XRPC response to internal types.RepoTreeResponse
369
-
files := make([]types.NiceTree, len(xrpcResp.Files))
370
-
for i, xrpcFile := range xrpcResp.Files {
371
-
file := types.NiceTree{
372
-
Name: xrpcFile.Name,
373
-
Mode: xrpcFile.Mode,
374
-
Size: int64(xrpcFile.Size),
375
-
IsFile: xrpcFile.Is_file,
376
-
IsSubtree: xrpcFile.Is_subtree,
377
-
}
378
-
379
-
// Convert last commit info if present
380
-
if xrpcFile.Last_commit != nil {
381
-
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
382
-
file.LastCommit = &types.LastCommitInfo{
383
-
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
384
-
Message: xrpcFile.Last_commit.Message,
385
-
When: commitWhen,
386
-
}
387
-
}
388
-
389
-
files[i] = file
390
-
}
391
-
392
-
result := types.RepoTreeResponse{
393
-
Ref: xrpcResp.Ref,
394
-
Files: files,
395
-
}
396
-
397
-
if xrpcResp.Parent != nil {
398
-
result.Parent = *xrpcResp.Parent
399
-
}
400
-
if xrpcResp.Dotdot != nil {
401
-
result.DotDot = *xrpcResp.Dotdot
402
-
}
403
-
if xrpcResp.Readme != nil {
404
-
result.ReadmeFileName = xrpcResp.Readme.Filename
405
-
result.Readme = xrpcResp.Readme.Contents
406
-
}
407
-
408
-
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
409
-
// so we can safely redirect to the "parent" (which is the same file).
410
-
if len(result.Files) == 0 && result.Parent == treePath {
411
-
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
412
-
http.Redirect(w, r, redirectTo, http.StatusFound)
413
-
return
414
-
}
415
-
416
-
user := rp.oauth.GetUser(r)
417
-
418
-
var breadcrumbs [][]string
419
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
420
-
if treePath != "" {
421
-
for idx, elem := range strings.Split(treePath, "/") {
422
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
423
-
}
424
-
}
425
-
426
-
sortFiles(result.Files)
427
-
428
-
rp.pages.RepoTree(w, pages.RepoTreeParams{
429
-
LoggedInUser: user,
430
-
BreadCrumbs: breadcrumbs,
431
-
TreePath: treePath,
432
-
RepoInfo: f.RepoInfo(user),
433
-
RepoTreeResponse: result,
434
-
})
435
-
}
436
-
437
-
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
438
-
l := rp.logger.With("handler", "RepoTags")
439
-
440
-
f, err := rp.repoResolver.Resolve(r)
441
-
if err != nil {
442
-
l.Error("failed to get repo and knot", "err", err)
443
-
return
444
-
}
445
-
446
-
scheme := "http"
447
-
if !rp.config.Core.Dev {
448
-
scheme = "https"
449
-
}
450
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
451
-
xrpcc := &indigoxrpc.Client{
452
-
Host: host,
453
-
}
454
-
455
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
456
-
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
457
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
458
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
459
-
rp.pages.Error503(w)
460
-
return
461
-
}
462
-
463
-
var result types.RepoTagsResponse
464
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
465
-
l.Error("failed to decode XRPC response", "err", err)
466
-
rp.pages.Error503(w)
467
-
return
468
-
}
469
-
470
-
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
471
-
if err != nil {
472
-
l.Error("failed grab artifacts", "err", err)
473
-
return
474
-
}
475
-
476
-
// convert artifacts to map for easy UI building
477
-
artifactMap := make(map[plumbing.Hash][]models.Artifact)
478
-
for _, a := range artifacts {
479
-
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
480
-
}
481
-
482
-
var danglingArtifacts []models.Artifact
483
-
for _, a := range artifacts {
484
-
found := false
485
-
for _, t := range result.Tags {
486
-
if t.Tag != nil {
487
-
if t.Tag.Hash == a.Tag {
488
-
found = true
489
-
}
490
-
}
491
-
}
492
-
493
-
if !found {
494
-
danglingArtifacts = append(danglingArtifacts, a)
495
-
}
496
-
}
497
-
498
-
user := rp.oauth.GetUser(r)
499
-
rp.pages.RepoTags(w, pages.RepoTagsParams{
500
-
LoggedInUser: user,
501
-
RepoInfo: f.RepoInfo(user),
502
-
RepoTagsResponse: result,
503
-
ArtifactMap: artifactMap,
504
-
DanglingArtifacts: danglingArtifacts,
505
-
})
506
-
}
507
-
508
-
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
509
-
l := rp.logger.With("handler", "RepoBranches")
510
-
511
-
f, err := rp.repoResolver.Resolve(r)
512
-
if err != nil {
513
-
l.Error("failed to get repo and knot", "err", err)
514
-
return
515
-
}
516
-
517
-
scheme := "http"
518
-
if !rp.config.Core.Dev {
519
-
scheme = "https"
520
-
}
521
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
522
-
xrpcc := &indigoxrpc.Client{
523
-
Host: host,
524
-
}
525
-
526
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
527
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
528
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
529
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
530
-
rp.pages.Error503(w)
531
-
return
532
-
}
533
-
534
-
var result types.RepoBranchesResponse
535
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
536
-
l.Error("failed to decode XRPC response", "err", err)
537
-
rp.pages.Error503(w)
538
-
return
539
-
}
540
-
541
-
sortBranches(result.Branches)
542
-
543
-
user := rp.oauth.GetUser(r)
544
-
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
545
-
LoggedInUser: user,
546
-
RepoInfo: f.RepoInfo(user),
547
-
RepoBranchesResponse: result,
548
-
})
549
-
}
550
-
551
-
func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) {
552
-
l := rp.logger.With("handler", "DeleteBranch")
553
-
554
-
f, err := rp.repoResolver.Resolve(r)
555
-
if err != nil {
556
-
l.Error("failed to get repo and knot", "err", err)
557
-
return
558
-
}
559
-
560
-
noticeId := "delete-branch-error"
561
-
fail := func(msg string, err error) {
562
-
l.Error(msg, "err", err)
563
-
rp.pages.Notice(w, noticeId, msg)
564
-
}
565
-
566
-
branch := r.FormValue("branch")
567
-
if branch == "" {
568
-
fail("No branch provided.", nil)
569
-
return
570
-
}
571
-
572
-
client, err := rp.oauth.ServiceClient(
573
-
r,
574
-
oauth.WithService(f.Knot),
575
-
oauth.WithLxm(tangled.RepoDeleteBranchNSID),
576
-
oauth.WithDev(rp.config.Core.Dev),
577
-
)
578
-
if err != nil {
579
-
fail("Failed to connect to knotserver", nil)
580
-
return
581
-
}
582
-
583
-
err = tangled.RepoDeleteBranch(
584
-
r.Context(),
585
-
client,
586
-
&tangled.RepoDeleteBranch_Input{
587
-
Branch: branch,
588
-
Repo: f.RepoAt().String(),
589
-
},
590
-
)
591
-
if err := xrpcclient.HandleXrpcErr(err); err != nil {
592
-
fail(fmt.Sprintf("Failed to delete branch: %s", err), err)
593
-
return
594
-
}
595
-
l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt())
596
-
597
-
rp.pages.HxRefresh(w)
598
-
}
599
-
600
-
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
601
-
l := rp.logger.With("handler", "RepoBlob")
602
-
603
-
f, err := rp.repoResolver.Resolve(r)
604
-
if err != nil {
605
-
l.Error("failed to get repo and knot", "err", err)
606
-
return
607
-
}
608
-
609
-
ref := chi.URLParam(r, "ref")
610
-
ref, _ = url.PathUnescape(ref)
611
-
612
-
filePath := chi.URLParam(r, "*")
613
-
filePath, _ = url.PathUnescape(filePath)
614
-
615
-
scheme := "http"
616
-
if !rp.config.Core.Dev {
617
-
scheme = "https"
618
-
}
619
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
620
-
xrpcc := &indigoxrpc.Client{
621
-
Host: host,
622
-
}
623
-
624
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
625
-
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
626
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
627
-
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
628
-
rp.pages.Error503(w)
629
-
return
630
-
}
631
-
632
-
// Use XRPC response directly instead of converting to internal types
633
-
634
-
var breadcrumbs [][]string
635
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
636
-
if filePath != "" {
637
-
for idx, elem := range strings.Split(filePath, "/") {
638
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
639
-
}
640
-
}
641
-
642
-
showRendered := false
643
-
renderToggle := false
644
-
645
-
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
646
-
renderToggle = true
647
-
showRendered = r.URL.Query().Get("code") != "true"
648
-
}
649
-
650
-
var unsupported bool
651
-
var isImage bool
652
-
var isVideo bool
653
-
var contentSrc string
654
-
655
-
if resp.IsBinary != nil && *resp.IsBinary {
656
-
ext := strings.ToLower(filepath.Ext(resp.Path))
657
-
switch ext {
658
-
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
659
-
isImage = true
660
-
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
661
-
isVideo = true
662
-
default:
663
-
unsupported = true
664
-
}
665
-
666
-
// fetch the raw binary content using sh.tangled.repo.blob xrpc
667
-
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
668
-
669
-
baseURL := &url.URL{
670
-
Scheme: scheme,
671
-
Host: f.Knot,
672
-
Path: "/xrpc/sh.tangled.repo.blob",
673
-
}
674
-
query := baseURL.Query()
675
-
query.Set("repo", repoName)
676
-
query.Set("ref", ref)
677
-
query.Set("path", filePath)
678
-
query.Set("raw", "true")
679
-
baseURL.RawQuery = query.Encode()
680
-
blobURL := baseURL.String()
681
-
682
-
contentSrc = blobURL
683
-
if !rp.config.Core.Dev {
684
-
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
685
-
}
686
-
}
687
-
688
-
lines := 0
689
-
if resp.IsBinary == nil || !*resp.IsBinary {
690
-
lines = strings.Count(resp.Content, "\n") + 1
691
-
}
692
-
693
-
var sizeHint uint64
694
-
if resp.Size != nil {
695
-
sizeHint = uint64(*resp.Size)
696
-
} else {
697
-
sizeHint = uint64(len(resp.Content))
698
-
}
699
-
700
-
user := rp.oauth.GetUser(r)
701
-
702
-
// Determine if content is binary (dereference pointer)
703
-
isBinary := false
704
-
if resp.IsBinary != nil {
705
-
isBinary = *resp.IsBinary
706
-
}
707
-
708
-
rp.pages.RepoBlob(w, pages.RepoBlobParams{
709
-
LoggedInUser: user,
710
-
RepoInfo: f.RepoInfo(user),
711
-
BreadCrumbs: breadcrumbs,
712
-
ShowRendered: showRendered,
713
-
RenderToggle: renderToggle,
714
-
Unsupported: unsupported,
715
-
IsImage: isImage,
716
-
IsVideo: isVideo,
717
-
ContentSrc: contentSrc,
718
-
RepoBlob_Output: resp,
719
-
Contents: resp.Content,
720
-
Lines: lines,
721
-
SizeHint: sizeHint,
722
-
IsBinary: isBinary,
723
-
})
724
-
}
725
-
726
-
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
727
-
l := rp.logger.With("handler", "RepoBlobRaw")
728
-
729
-
f, err := rp.repoResolver.Resolve(r)
730
-
if err != nil {
731
-
l.Error("failed to get repo and knot", "err", err)
732
-
w.WriteHeader(http.StatusBadRequest)
733
-
return
734
-
}
735
-
736
-
ref := chi.URLParam(r, "ref")
737
-
ref, _ = url.PathUnescape(ref)
738
-
739
-
filePath := chi.URLParam(r, "*")
740
-
filePath, _ = url.PathUnescape(filePath)
741
-
742
-
scheme := "http"
743
-
if !rp.config.Core.Dev {
744
-
scheme = "https"
745
-
}
746
-
747
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
748
-
baseURL := &url.URL{
749
-
Scheme: scheme,
750
-
Host: f.Knot,
751
-
Path: "/xrpc/sh.tangled.repo.blob",
752
-
}
753
-
query := baseURL.Query()
754
-
query.Set("repo", repo)
755
-
query.Set("ref", ref)
756
-
query.Set("path", filePath)
757
-
query.Set("raw", "true")
758
-
baseURL.RawQuery = query.Encode()
759
-
blobURL := baseURL.String()
760
-
761
-
req, err := http.NewRequest("GET", blobURL, nil)
762
-
if err != nil {
763
-
l.Error("failed to create request", "err", err)
764
-
return
765
-
}
766
-
767
-
// forward the If-None-Match header
768
-
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
769
-
req.Header.Set("If-None-Match", clientETag)
770
-
}
771
-
772
-
client := &http.Client{}
773
-
resp, err := client.Do(req)
774
-
if err != nil {
775
-
l.Error("failed to reach knotserver", "err", err)
776
-
rp.pages.Error503(w)
777
-
return
778
-
}
779
-
defer resp.Body.Close()
780
-
781
-
// forward 304 not modified
782
-
if resp.StatusCode == http.StatusNotModified {
783
-
w.WriteHeader(http.StatusNotModified)
784
-
return
785
-
}
786
-
787
-
if resp.StatusCode != http.StatusOK {
788
-
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
789
-
w.WriteHeader(resp.StatusCode)
790
-
_, _ = io.Copy(w, resp.Body)
791
-
return
792
-
}
793
-
794
-
contentType := resp.Header.Get("Content-Type")
795
-
body, err := io.ReadAll(resp.Body)
796
-
if err != nil {
797
-
l.Error("error reading response body from knotserver", "err", err)
798
-
w.WriteHeader(http.StatusInternalServerError)
799
-
return
800
-
}
801
-
802
-
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
803
-
// serve all textual content as text/plain
804
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
805
-
w.Write(body)
806
-
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
807
-
// serve images and videos with their original content type
808
-
w.Header().Set("Content-Type", contentType)
809
-
w.Write(body)
810
-
} else {
811
-
w.WriteHeader(http.StatusUnsupportedMediaType)
812
-
w.Write([]byte("unsupported content type"))
813
-
return
814
-
}
815
-
}
816
-
817
-
// isTextualMimeType returns true if the MIME type represents textual content
818
-
// that should be served as text/plain
819
-
func isTextualMimeType(mimeType string) bool {
820
-
textualTypes := []string{
821
-
"application/json",
822
-
"application/xml",
823
-
"application/yaml",
824
-
"application/x-yaml",
825
-
"application/toml",
826
-
"application/javascript",
827
-
"application/ecmascript",
828
-
"message/",
829
-
}
830
-
831
-
return slices.Contains(textualTypes, mimeType)
832
-
}
833
-
834
81
// modify the spindle configured for this repo
835
82
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
836
83
user := rp.oauth.GetUser(r)
···
1686
933
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1687
934
}
1688
935
1689
-
func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) {
1690
-
l := rp.logger.With("handler", "EditBaseSettings")
1691
-
1692
-
noticeId := "repo-base-settings-error"
1693
-
1694
-
f, err := rp.repoResolver.Resolve(r)
1695
-
if err != nil {
1696
-
l.Error("failed to get repo and knot", "err", err)
1697
-
w.WriteHeader(http.StatusBadRequest)
1698
-
return
1699
-
}
1700
-
1701
-
client, err := rp.oauth.AuthorizedClient(r)
1702
-
if err != nil {
1703
-
l.Error("failed to get client")
1704
-
rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.")
1705
-
return
1706
-
}
1707
-
1708
-
var (
1709
-
description = r.FormValue("description")
1710
-
website = r.FormValue("website")
1711
-
topicStr = r.FormValue("topics")
1712
-
)
1713
-
1714
-
err = rp.validator.ValidateURI(website)
1715
-
if err != nil {
1716
-
l.Error("invalid uri", "err", err)
1717
-
rp.pages.Notice(w, noticeId, err.Error())
1718
-
return
1719
-
}
1720
-
1721
-
topics, err := rp.validator.ValidateRepoTopicStr(topicStr)
1722
-
if err != nil {
1723
-
l.Error("invalid topics", "err", err)
1724
-
rp.pages.Notice(w, noticeId, err.Error())
1725
-
return
1726
-
}
1727
-
l.Debug("got", "topicsStr", topicStr, "topics", topics)
1728
-
1729
-
newRepo := f.Repo
1730
-
newRepo.Description = description
1731
-
newRepo.Website = website
1732
-
newRepo.Topics = topics
1733
-
record := newRepo.AsRecord()
1734
-
1735
-
tx, err := rp.db.BeginTx(r.Context(), nil)
1736
-
if err != nil {
1737
-
l.Error("failed to begin transaction", "err", err)
1738
-
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
1739
-
return
1740
-
}
1741
-
defer tx.Rollback()
1742
-
1743
-
err = db.PutRepo(tx, newRepo)
1744
-
if err != nil {
1745
-
l.Error("failed to update repository", "err", err)
1746
-
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
1747
-
return
1748
-
}
1749
-
1750
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1751
-
if err != nil {
1752
-
// failed to get record
1753
-
l.Error("failed to get repo record", "err", err)
1754
-
rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.")
1755
-
return
1756
-
}
1757
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1758
-
Collection: tangled.RepoNSID,
1759
-
Repo: newRepo.Did,
1760
-
Rkey: newRepo.Rkey,
1761
-
SwapRecord: ex.Cid,
1762
-
Record: &lexutil.LexiconTypeDecoder{
1763
-
Val: &record,
1764
-
},
1765
-
})
1766
-
1767
-
if err != nil {
1768
-
l.Error("failed to perferom update-repo query", "err", err)
1769
-
// failed to get record
1770
-
rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.")
1771
-
return
1772
-
}
1773
-
1774
-
err = tx.Commit()
1775
-
if err != nil {
1776
-
l.Error("failed to commit", "err", err)
1777
-
}
1778
-
1779
-
rp.pages.HxRefresh(w)
1780
-
}
1781
-
1782
-
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1783
-
l := rp.logger.With("handler", "SetDefaultBranch")
1784
-
1785
-
f, err := rp.repoResolver.Resolve(r)
1786
-
if err != nil {
1787
-
l.Error("failed to get repo and knot", "err", err)
1788
-
return
1789
-
}
1790
-
1791
-
noticeId := "operation-error"
1792
-
branch := r.FormValue("branch")
1793
-
if branch == "" {
1794
-
http.Error(w, "malformed form", http.StatusBadRequest)
1795
-
return
1796
-
}
1797
-
1798
-
client, err := rp.oauth.ServiceClient(
1799
-
r,
1800
-
oauth.WithService(f.Knot),
1801
-
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
1802
-
oauth.WithDev(rp.config.Core.Dev),
1803
-
)
1804
-
if err != nil {
1805
-
l.Error("failed to connect to knot server", "err", err)
1806
-
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1807
-
return
1808
-
}
1809
-
1810
-
xe := tangled.RepoSetDefaultBranch(
1811
-
r.Context(),
1812
-
client,
1813
-
&tangled.RepoSetDefaultBranch_Input{
1814
-
Repo: f.RepoAt().String(),
1815
-
DefaultBranch: branch,
1816
-
},
1817
-
)
1818
-
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1819
-
l.Error("xrpc failed", "err", xe)
1820
-
rp.pages.Notice(w, noticeId, err.Error())
1821
-
return
1822
-
}
1823
-
1824
-
rp.pages.HxRefresh(w)
1825
-
}
1826
-
1827
-
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1828
-
user := rp.oauth.GetUser(r)
1829
-
l := rp.logger.With("handler", "Secrets")
1830
-
l = l.With("did", user.Did)
1831
-
1832
-
f, err := rp.repoResolver.Resolve(r)
1833
-
if err != nil {
1834
-
l.Error("failed to get repo and knot", "err", err)
1835
-
return
1836
-
}
1837
-
1838
-
if f.Spindle == "" {
1839
-
l.Error("empty spindle cannot add/rm secret", "err", err)
1840
-
return
1841
-
}
1842
-
1843
-
lxm := tangled.RepoAddSecretNSID
1844
-
if r.Method == http.MethodDelete {
1845
-
lxm = tangled.RepoRemoveSecretNSID
1846
-
}
1847
-
1848
-
spindleClient, err := rp.oauth.ServiceClient(
1849
-
r,
1850
-
oauth.WithService(f.Spindle),
1851
-
oauth.WithLxm(lxm),
1852
-
oauth.WithExp(60),
1853
-
oauth.WithDev(rp.config.Core.Dev),
1854
-
)
1855
-
if err != nil {
1856
-
l.Error("failed to create spindle client", "err", err)
1857
-
return
1858
-
}
1859
-
1860
-
key := r.FormValue("key")
1861
-
if key == "" {
1862
-
w.WriteHeader(http.StatusBadRequest)
1863
-
return
1864
-
}
1865
-
1866
-
switch r.Method {
1867
-
case http.MethodPut:
1868
-
errorId := "add-secret-error"
1869
-
1870
-
value := r.FormValue("value")
1871
-
if value == "" {
1872
-
w.WriteHeader(http.StatusBadRequest)
1873
-
return
1874
-
}
1875
-
1876
-
err = tangled.RepoAddSecret(
1877
-
r.Context(),
1878
-
spindleClient,
1879
-
&tangled.RepoAddSecret_Input{
1880
-
Repo: f.RepoAt().String(),
1881
-
Key: key,
1882
-
Value: value,
1883
-
},
1884
-
)
1885
-
if err != nil {
1886
-
l.Error("Failed to add secret.", "err", err)
1887
-
rp.pages.Notice(w, errorId, "Failed to add secret.")
1888
-
return
1889
-
}
1890
-
1891
-
case http.MethodDelete:
1892
-
errorId := "operation-error"
1893
-
1894
-
err = tangled.RepoRemoveSecret(
1895
-
r.Context(),
1896
-
spindleClient,
1897
-
&tangled.RepoRemoveSecret_Input{
1898
-
Repo: f.RepoAt().String(),
1899
-
Key: key,
1900
-
},
1901
-
)
1902
-
if err != nil {
1903
-
l.Error("Failed to delete secret.", "err", err)
1904
-
rp.pages.Notice(w, errorId, "Failed to delete secret.")
1905
-
return
1906
-
}
1907
-
}
1908
-
1909
-
rp.pages.HxRefresh(w)
1910
-
}
1911
-
1912
-
type tab = map[string]any
1913
-
1914
-
var (
1915
-
// would be great to have ordered maps right about now
1916
-
settingsTabs []tab = []tab{
1917
-
{"Name": "general", "Icon": "sliders-horizontal"},
1918
-
{"Name": "access", "Icon": "users"},
1919
-
{"Name": "pipelines", "Icon": "layers-2"},
1920
-
}
1921
-
)
1922
-
1923
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1924
-
tabVal := r.URL.Query().Get("tab")
1925
-
if tabVal == "" {
1926
-
tabVal = "general"
1927
-
}
1928
-
1929
-
switch tabVal {
1930
-
case "general":
1931
-
rp.generalSettings(w, r)
1932
-
1933
-
case "access":
1934
-
rp.accessSettings(w, r)
1935
-
1936
-
case "pipelines":
1937
-
rp.pipelineSettings(w, r)
1938
-
}
1939
-
}
1940
-
1941
-
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1942
-
l := rp.logger.With("handler", "generalSettings")
1943
-
1944
-
f, err := rp.repoResolver.Resolve(r)
1945
-
user := rp.oauth.GetUser(r)
1946
-
1947
-
scheme := "http"
1948
-
if !rp.config.Core.Dev {
1949
-
scheme = "https"
1950
-
}
1951
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1952
-
xrpcc := &indigoxrpc.Client{
1953
-
Host: host,
1954
-
}
1955
-
1956
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1957
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1958
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1959
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
1960
-
rp.pages.Error503(w)
1961
-
return
1962
-
}
1963
-
1964
-
var result types.RepoBranchesResponse
1965
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1966
-
l.Error("failed to decode XRPC response", "err", err)
1967
-
rp.pages.Error503(w)
1968
-
return
1969
-
}
1970
-
1971
-
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs))
1972
-
if err != nil {
1973
-
l.Error("failed to fetch labels", "err", err)
1974
-
rp.pages.Error503(w)
1975
-
return
1976
-
}
1977
-
1978
-
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
1979
-
if err != nil {
1980
-
l.Error("failed to fetch labels", "err", err)
1981
-
rp.pages.Error503(w)
1982
-
return
1983
-
}
1984
-
// remove default labels from the labels list, if present
1985
-
defaultLabelMap := make(map[string]bool)
1986
-
for _, dl := range defaultLabels {
1987
-
defaultLabelMap[dl.AtUri().String()] = true
1988
-
}
1989
-
n := 0
1990
-
for _, l := range labels {
1991
-
if !defaultLabelMap[l.AtUri().String()] {
1992
-
labels[n] = l
1993
-
n++
1994
-
}
1995
-
}
1996
-
labels = labels[:n]
1997
-
1998
-
subscribedLabels := make(map[string]struct{})
1999
-
for _, l := range f.Repo.Labels {
2000
-
subscribedLabels[l] = struct{}{}
2001
-
}
2002
-
2003
-
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
2004
-
// if all default labels are subbed, show the "unsubscribe all" button
2005
-
shouldSubscribeAll := false
2006
-
for _, dl := range defaultLabels {
2007
-
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
2008
-
// one of the default labels is not subscribed to
2009
-
shouldSubscribeAll = true
2010
-
break
2011
-
}
2012
-
}
2013
-
2014
-
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
2015
-
LoggedInUser: user,
2016
-
RepoInfo: f.RepoInfo(user),
2017
-
Branches: result.Branches,
2018
-
Labels: labels,
2019
-
DefaultLabels: defaultLabels,
2020
-
SubscribedLabels: subscribedLabels,
2021
-
ShouldSubscribeAll: shouldSubscribeAll,
2022
-
Tabs: settingsTabs,
2023
-
Tab: "general",
2024
-
})
2025
-
}
2026
-
2027
-
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
2028
-
l := rp.logger.With("handler", "accessSettings")
2029
-
2030
-
f, err := rp.repoResolver.Resolve(r)
2031
-
user := rp.oauth.GetUser(r)
2032
-
2033
-
repoCollaborators, err := f.Collaborators(r.Context())
2034
-
if err != nil {
2035
-
l.Error("failed to get collaborators", "err", err)
2036
-
}
2037
-
2038
-
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
2039
-
LoggedInUser: user,
2040
-
RepoInfo: f.RepoInfo(user),
2041
-
Tabs: settingsTabs,
2042
-
Tab: "access",
2043
-
Collaborators: repoCollaborators,
2044
-
})
2045
-
}
2046
-
2047
-
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
2048
-
l := rp.logger.With("handler", "pipelineSettings")
2049
-
2050
-
f, err := rp.repoResolver.Resolve(r)
2051
-
user := rp.oauth.GetUser(r)
2052
-
2053
-
// all spindles that the repo owner is a member of
2054
-
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
2055
-
if err != nil {
2056
-
l.Error("failed to fetch spindles", "err", err)
2057
-
return
2058
-
}
2059
-
2060
-
var secrets []*tangled.RepoListSecrets_Secret
2061
-
if f.Spindle != "" {
2062
-
if spindleClient, err := rp.oauth.ServiceClient(
2063
-
r,
2064
-
oauth.WithService(f.Spindle),
2065
-
oauth.WithLxm(tangled.RepoListSecretsNSID),
2066
-
oauth.WithExp(60),
2067
-
oauth.WithDev(rp.config.Core.Dev),
2068
-
); err != nil {
2069
-
l.Error("failed to create spindle client", "err", err)
2070
-
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
2071
-
l.Error("failed to fetch secrets", "err", err)
2072
-
} else {
2073
-
secrets = resp.Secrets
2074
-
}
2075
-
}
2076
-
2077
-
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
2078
-
return strings.Compare(a.Key, b.Key)
2079
-
})
2080
-
2081
-
var dids []string
2082
-
for _, s := range secrets {
2083
-
dids = append(dids, s.CreatedBy)
2084
-
}
2085
-
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
2086
-
2087
-
// convert to a more manageable form
2088
-
var niceSecret []map[string]any
2089
-
for id, s := range secrets {
2090
-
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
2091
-
niceSecret = append(niceSecret, map[string]any{
2092
-
"Id": id,
2093
-
"Key": s.Key,
2094
-
"CreatedAt": when,
2095
-
"CreatedBy": resolvedIdents[id].Handle.String(),
2096
-
})
2097
-
}
2098
-
2099
-
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
2100
-
LoggedInUser: user,
2101
-
RepoInfo: f.RepoInfo(user),
2102
-
Tabs: settingsTabs,
2103
-
Tab: "pipelines",
2104
-
Spindles: spindles,
2105
-
CurrentSpindle: f.Spindle,
2106
-
Secrets: niceSecret,
2107
-
})
2108
-
}
2109
-
2110
936
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
2111
937
l := rp.logger.With("handler", "SyncRepoFork")
2112
938
···
2388
1214
})
2389
1215
return err
2390
1216
}
2391
-
2392
-
func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
2393
-
l := rp.logger.With("handler", "RepoCompareNew")
2394
-
2395
-
user := rp.oauth.GetUser(r)
2396
-
f, err := rp.repoResolver.Resolve(r)
2397
-
if err != nil {
2398
-
l.Error("failed to get repo and knot", "err", err)
2399
-
return
2400
-
}
2401
-
2402
-
scheme := "http"
2403
-
if !rp.config.Core.Dev {
2404
-
scheme = "https"
2405
-
}
2406
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2407
-
xrpcc := &indigoxrpc.Client{
2408
-
Host: host,
2409
-
}
2410
-
2411
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2412
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2413
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2414
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
2415
-
rp.pages.Error503(w)
2416
-
return
2417
-
}
2418
-
2419
-
var branchResult types.RepoBranchesResponse
2420
-
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
2421
-
l.Error("failed to decode XRPC branches response", "err", err)
2422
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2423
-
return
2424
-
}
2425
-
branches := branchResult.Branches
2426
-
2427
-
sortBranches(branches)
2428
-
2429
-
var defaultBranch string
2430
-
for _, b := range branches {
2431
-
if b.IsDefault {
2432
-
defaultBranch = b.Name
2433
-
}
2434
-
}
2435
-
2436
-
base := defaultBranch
2437
-
head := defaultBranch
2438
-
2439
-
params := r.URL.Query()
2440
-
queryBase := params.Get("base")
2441
-
queryHead := params.Get("head")
2442
-
if queryBase != "" {
2443
-
base = queryBase
2444
-
}
2445
-
if queryHead != "" {
2446
-
head = queryHead
2447
-
}
2448
-
2449
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2450
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2451
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
2452
-
rp.pages.Error503(w)
2453
-
return
2454
-
}
2455
-
2456
-
var tags types.RepoTagsResponse
2457
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2458
-
l.Error("failed to decode XRPC tags response", "err", err)
2459
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2460
-
return
2461
-
}
2462
-
2463
-
repoinfo := f.RepoInfo(user)
2464
-
2465
-
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
2466
-
LoggedInUser: user,
2467
-
RepoInfo: repoinfo,
2468
-
Branches: branches,
2469
-
Tags: tags.Tags,
2470
-
Base: base,
2471
-
Head: head,
2472
-
})
2473
-
}
2474
-
2475
-
func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
2476
-
l := rp.logger.With("handler", "RepoCompare")
2477
-
2478
-
user := rp.oauth.GetUser(r)
2479
-
f, err := rp.repoResolver.Resolve(r)
2480
-
if err != nil {
2481
-
l.Error("failed to get repo and knot", "err", err)
2482
-
return
2483
-
}
2484
-
2485
-
var diffOpts types.DiffOpts
2486
-
if d := r.URL.Query().Get("diff"); d == "split" {
2487
-
diffOpts.Split = true
2488
-
}
2489
-
2490
-
// if user is navigating to one of
2491
-
// /compare/{base}/{head}
2492
-
// /compare/{base}...{head}
2493
-
base := chi.URLParam(r, "base")
2494
-
head := chi.URLParam(r, "head")
2495
-
if base == "" && head == "" {
2496
-
rest := chi.URLParam(r, "*") // master...feature/xyz
2497
-
parts := strings.SplitN(rest, "...", 2)
2498
-
if len(parts) == 2 {
2499
-
base = parts[0]
2500
-
head = parts[1]
2501
-
}
2502
-
}
2503
-
2504
-
base, _ = url.PathUnescape(base)
2505
-
head, _ = url.PathUnescape(head)
2506
-
2507
-
if base == "" || head == "" {
2508
-
l.Error("invalid comparison")
2509
-
rp.pages.Error404(w)
2510
-
return
2511
-
}
2512
-
2513
-
scheme := "http"
2514
-
if !rp.config.Core.Dev {
2515
-
scheme = "https"
2516
-
}
2517
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2518
-
xrpcc := &indigoxrpc.Client{
2519
-
Host: host,
2520
-
}
2521
-
2522
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2523
-
2524
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2525
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2526
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
2527
-
rp.pages.Error503(w)
2528
-
return
2529
-
}
2530
-
2531
-
var branches types.RepoBranchesResponse
2532
-
if err := json.Unmarshal(branchBytes, &branches); err != nil {
2533
-
l.Error("failed to decode XRPC branches response", "err", err)
2534
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2535
-
return
2536
-
}
2537
-
2538
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2539
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2540
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
2541
-
rp.pages.Error503(w)
2542
-
return
2543
-
}
2544
-
2545
-
var tags types.RepoTagsResponse
2546
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2547
-
l.Error("failed to decode XRPC tags response", "err", err)
2548
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2549
-
return
2550
-
}
2551
-
2552
-
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
2553
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2554
-
l.Error("failed to call XRPC repo.compare", "err", xrpcerr)
2555
-
rp.pages.Error503(w)
2556
-
return
2557
-
}
2558
-
2559
-
var formatPatch types.RepoFormatPatchResponse
2560
-
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
2561
-
l.Error("failed to decode XRPC compare response", "err", err)
2562
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2563
-
return
2564
-
}
2565
-
2566
-
var diff types.NiceDiff
2567
-
if formatPatch.CombinedPatchRaw != "" {
2568
-
diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base)
2569
-
} else {
2570
-
diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
2571
-
}
2572
-
2573
-
repoinfo := f.RepoInfo(user)
2574
-
2575
-
rp.pages.RepoCompare(w, pages.RepoCompareParams{
2576
-
LoggedInUser: user,
2577
-
RepoInfo: repoinfo,
2578
-
Branches: branches.Branches,
2579
-
Tags: tags.Tags,
2580
-
Base: base,
2581
-
Head: head,
2582
-
Diff: &diff,
2583
-
DiffOpts: diffOpts,
2584
-
})
2585
-
2586
-
}
+2
-2
appview/repo/repo_util.go
+2
-2
appview/repo/repo_util.go
+14
-14
appview/repo/router.go
+14
-14
appview/repo/router.go
···
9
9
10
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
11
r := chi.NewRouter()
12
-
r.Get("/", rp.RepoIndex)
13
-
r.Get("/opengraph", rp.RepoOpenGraphSummary)
14
-
r.Get("/feed.atom", rp.RepoAtomFeed)
15
-
r.Get("/commits/{ref}", rp.RepoLog)
12
+
r.Get("/", rp.Index)
13
+
r.Get("/opengraph", rp.Opengraph)
14
+
r.Get("/feed.atom", rp.AtomFeed)
15
+
r.Get("/commits/{ref}", rp.Log)
16
16
r.Route("/tree/{ref}", func(r chi.Router) {
17
-
r.Get("/", rp.RepoIndex)
18
-
r.Get("/*", rp.RepoTree)
17
+
r.Get("/", rp.Index)
18
+
r.Get("/*", rp.Tree)
19
19
})
20
-
r.Get("/commit/{ref}", rp.RepoCommit)
21
-
r.Get("/branches", rp.RepoBranches)
20
+
r.Get("/commit/{ref}", rp.Commit)
21
+
r.Get("/branches", rp.Branches)
22
22
r.Delete("/branches", rp.DeleteBranch)
23
23
r.Route("/tags", func(r chi.Router) {
24
-
r.Get("/", rp.RepoTags)
24
+
r.Get("/", rp.Tags)
25
25
r.Route("/{tag}", func(r chi.Router) {
26
26
r.Get("/download/{file}", rp.DownloadArtifact)
27
27
···
37
37
})
38
38
})
39
39
})
40
-
r.Get("/blob/{ref}/*", rp.RepoBlob)
40
+
r.Get("/blob/{ref}/*", rp.Blob)
41
41
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
42
42
43
43
// intentionally doesn't use /* as this isn't
···
54
54
})
55
55
56
56
r.Route("/compare", func(r chi.Router) {
57
-
r.Get("/", rp.RepoCompareNew) // start an new comparison
57
+
r.Get("/", rp.CompareNew) // start an new comparison
58
58
59
59
// we have to wildcard here since we want to support GitHub's compare syntax
60
60
// /compare/{ref1}...{ref2}
61
61
// for example:
62
62
// /compare/master...some/feature
63
63
// /compare/master...example.com:another/feature <- this is a fork
64
-
r.Get("/{base}/{head}", rp.RepoCompare)
65
-
r.Get("/*", rp.RepoCompare)
64
+
r.Get("/{base}/{head}", rp.Compare)
65
+
r.Get("/*", rp.Compare)
66
66
})
67
67
68
68
// label panel in issues/pulls/discussions/tasks
···
75
75
r.Group(func(r chi.Router) {
76
76
r.Use(middleware.AuthMiddleware(rp.oauth))
77
77
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
78
-
r.Get("/", rp.RepoSettings)
78
+
r.Get("/", rp.Settings)
79
79
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings)
80
80
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
81
81
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
+442
appview/repo/settings.go
+442
appview/repo/settings.go
···
1
+
package repo
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"slices"
8
+
"strings"
9
+
"time"
10
+
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/oauth"
14
+
"tangled.org/core/appview/pages"
15
+
xrpcclient "tangled.org/core/appview/xrpcclient"
16
+
"tangled.org/core/types"
17
+
18
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
19
+
lexutil "github.com/bluesky-social/indigo/lex/util"
20
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
21
+
)
22
+
23
+
type tab = map[string]any
24
+
25
+
var (
26
+
// would be great to have ordered maps right about now
27
+
settingsTabs []tab = []tab{
28
+
{"Name": "general", "Icon": "sliders-horizontal"},
29
+
{"Name": "access", "Icon": "users"},
30
+
{"Name": "pipelines", "Icon": "layers-2"},
31
+
}
32
+
)
33
+
34
+
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
35
+
l := rp.logger.With("handler", "SetDefaultBranch")
36
+
37
+
f, err := rp.repoResolver.Resolve(r)
38
+
if err != nil {
39
+
l.Error("failed to get repo and knot", "err", err)
40
+
return
41
+
}
42
+
43
+
noticeId := "operation-error"
44
+
branch := r.FormValue("branch")
45
+
if branch == "" {
46
+
http.Error(w, "malformed form", http.StatusBadRequest)
47
+
return
48
+
}
49
+
50
+
client, err := rp.oauth.ServiceClient(
51
+
r,
52
+
oauth.WithService(f.Knot),
53
+
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
54
+
oauth.WithDev(rp.config.Core.Dev),
55
+
)
56
+
if err != nil {
57
+
l.Error("failed to connect to knot server", "err", err)
58
+
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
59
+
return
60
+
}
61
+
62
+
xe := tangled.RepoSetDefaultBranch(
63
+
r.Context(),
64
+
client,
65
+
&tangled.RepoSetDefaultBranch_Input{
66
+
Repo: f.RepoAt().String(),
67
+
DefaultBranch: branch,
68
+
},
69
+
)
70
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
71
+
l.Error("xrpc failed", "err", xe)
72
+
rp.pages.Notice(w, noticeId, err.Error())
73
+
return
74
+
}
75
+
76
+
rp.pages.HxRefresh(w)
77
+
}
78
+
79
+
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
80
+
user := rp.oauth.GetUser(r)
81
+
l := rp.logger.With("handler", "Secrets")
82
+
l = l.With("did", user.Did)
83
+
84
+
f, err := rp.repoResolver.Resolve(r)
85
+
if err != nil {
86
+
l.Error("failed to get repo and knot", "err", err)
87
+
return
88
+
}
89
+
90
+
if f.Spindle == "" {
91
+
l.Error("empty spindle cannot add/rm secret", "err", err)
92
+
return
93
+
}
94
+
95
+
lxm := tangled.RepoAddSecretNSID
96
+
if r.Method == http.MethodDelete {
97
+
lxm = tangled.RepoRemoveSecretNSID
98
+
}
99
+
100
+
spindleClient, err := rp.oauth.ServiceClient(
101
+
r,
102
+
oauth.WithService(f.Spindle),
103
+
oauth.WithLxm(lxm),
104
+
oauth.WithExp(60),
105
+
oauth.WithDev(rp.config.Core.Dev),
106
+
)
107
+
if err != nil {
108
+
l.Error("failed to create spindle client", "err", err)
109
+
return
110
+
}
111
+
112
+
key := r.FormValue("key")
113
+
if key == "" {
114
+
w.WriteHeader(http.StatusBadRequest)
115
+
return
116
+
}
117
+
118
+
switch r.Method {
119
+
case http.MethodPut:
120
+
errorId := "add-secret-error"
121
+
122
+
value := r.FormValue("value")
123
+
if value == "" {
124
+
w.WriteHeader(http.StatusBadRequest)
125
+
return
126
+
}
127
+
128
+
err = tangled.RepoAddSecret(
129
+
r.Context(),
130
+
spindleClient,
131
+
&tangled.RepoAddSecret_Input{
132
+
Repo: f.RepoAt().String(),
133
+
Key: key,
134
+
Value: value,
135
+
},
136
+
)
137
+
if err != nil {
138
+
l.Error("Failed to add secret.", "err", err)
139
+
rp.pages.Notice(w, errorId, "Failed to add secret.")
140
+
return
141
+
}
142
+
143
+
case http.MethodDelete:
144
+
errorId := "operation-error"
145
+
146
+
err = tangled.RepoRemoveSecret(
147
+
r.Context(),
148
+
spindleClient,
149
+
&tangled.RepoRemoveSecret_Input{
150
+
Repo: f.RepoAt().String(),
151
+
Key: key,
152
+
},
153
+
)
154
+
if err != nil {
155
+
l.Error("Failed to delete secret.", "err", err)
156
+
rp.pages.Notice(w, errorId, "Failed to delete secret.")
157
+
return
158
+
}
159
+
}
160
+
161
+
rp.pages.HxRefresh(w)
162
+
}
163
+
164
+
func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) {
165
+
tabVal := r.URL.Query().Get("tab")
166
+
if tabVal == "" {
167
+
tabVal = "general"
168
+
}
169
+
170
+
switch tabVal {
171
+
case "general":
172
+
rp.generalSettings(w, r)
173
+
174
+
case "access":
175
+
rp.accessSettings(w, r)
176
+
177
+
case "pipelines":
178
+
rp.pipelineSettings(w, r)
179
+
}
180
+
}
181
+
182
+
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
183
+
l := rp.logger.With("handler", "generalSettings")
184
+
185
+
f, err := rp.repoResolver.Resolve(r)
186
+
user := rp.oauth.GetUser(r)
187
+
188
+
scheme := "http"
189
+
if !rp.config.Core.Dev {
190
+
scheme = "https"
191
+
}
192
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
193
+
xrpcc := &indigoxrpc.Client{
194
+
Host: host,
195
+
}
196
+
197
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
198
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
199
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
200
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
201
+
rp.pages.Error503(w)
202
+
return
203
+
}
204
+
205
+
var result types.RepoBranchesResponse
206
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
207
+
l.Error("failed to decode XRPC response", "err", err)
208
+
rp.pages.Error503(w)
209
+
return
210
+
}
211
+
212
+
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs))
213
+
if err != nil {
214
+
l.Error("failed to fetch labels", "err", err)
215
+
rp.pages.Error503(w)
216
+
return
217
+
}
218
+
219
+
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
220
+
if err != nil {
221
+
l.Error("failed to fetch labels", "err", err)
222
+
rp.pages.Error503(w)
223
+
return
224
+
}
225
+
// remove default labels from the labels list, if present
226
+
defaultLabelMap := make(map[string]bool)
227
+
for _, dl := range defaultLabels {
228
+
defaultLabelMap[dl.AtUri().String()] = true
229
+
}
230
+
n := 0
231
+
for _, l := range labels {
232
+
if !defaultLabelMap[l.AtUri().String()] {
233
+
labels[n] = l
234
+
n++
235
+
}
236
+
}
237
+
labels = labels[:n]
238
+
239
+
subscribedLabels := make(map[string]struct{})
240
+
for _, l := range f.Repo.Labels {
241
+
subscribedLabels[l] = struct{}{}
242
+
}
243
+
244
+
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
245
+
// if all default labels are subbed, show the "unsubscribe all" button
246
+
shouldSubscribeAll := false
247
+
for _, dl := range defaultLabels {
248
+
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
249
+
// one of the default labels is not subscribed to
250
+
shouldSubscribeAll = true
251
+
break
252
+
}
253
+
}
254
+
255
+
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
256
+
LoggedInUser: user,
257
+
RepoInfo: f.RepoInfo(user),
258
+
Branches: result.Branches,
259
+
Labels: labels,
260
+
DefaultLabels: defaultLabels,
261
+
SubscribedLabels: subscribedLabels,
262
+
ShouldSubscribeAll: shouldSubscribeAll,
263
+
Tabs: settingsTabs,
264
+
Tab: "general",
265
+
})
266
+
}
267
+
268
+
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
269
+
l := rp.logger.With("handler", "accessSettings")
270
+
271
+
f, err := rp.repoResolver.Resolve(r)
272
+
user := rp.oauth.GetUser(r)
273
+
274
+
repoCollaborators, err := f.Collaborators(r.Context())
275
+
if err != nil {
276
+
l.Error("failed to get collaborators", "err", err)
277
+
}
278
+
279
+
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
280
+
LoggedInUser: user,
281
+
RepoInfo: f.RepoInfo(user),
282
+
Tabs: settingsTabs,
283
+
Tab: "access",
284
+
Collaborators: repoCollaborators,
285
+
})
286
+
}
287
+
288
+
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
289
+
l := rp.logger.With("handler", "pipelineSettings")
290
+
291
+
f, err := rp.repoResolver.Resolve(r)
292
+
user := rp.oauth.GetUser(r)
293
+
294
+
// all spindles that the repo owner is a member of
295
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
296
+
if err != nil {
297
+
l.Error("failed to fetch spindles", "err", err)
298
+
return
299
+
}
300
+
301
+
var secrets []*tangled.RepoListSecrets_Secret
302
+
if f.Spindle != "" {
303
+
if spindleClient, err := rp.oauth.ServiceClient(
304
+
r,
305
+
oauth.WithService(f.Spindle),
306
+
oauth.WithLxm(tangled.RepoListSecretsNSID),
307
+
oauth.WithExp(60),
308
+
oauth.WithDev(rp.config.Core.Dev),
309
+
); err != nil {
310
+
l.Error("failed to create spindle client", "err", err)
311
+
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
312
+
l.Error("failed to fetch secrets", "err", err)
313
+
} else {
314
+
secrets = resp.Secrets
315
+
}
316
+
}
317
+
318
+
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
319
+
return strings.Compare(a.Key, b.Key)
320
+
})
321
+
322
+
var dids []string
323
+
for _, s := range secrets {
324
+
dids = append(dids, s.CreatedBy)
325
+
}
326
+
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
327
+
328
+
// convert to a more manageable form
329
+
var niceSecret []map[string]any
330
+
for id, s := range secrets {
331
+
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
332
+
niceSecret = append(niceSecret, map[string]any{
333
+
"Id": id,
334
+
"Key": s.Key,
335
+
"CreatedAt": when,
336
+
"CreatedBy": resolvedIdents[id].Handle.String(),
337
+
})
338
+
}
339
+
340
+
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
341
+
LoggedInUser: user,
342
+
RepoInfo: f.RepoInfo(user),
343
+
Tabs: settingsTabs,
344
+
Tab: "pipelines",
345
+
Spindles: spindles,
346
+
CurrentSpindle: f.Spindle,
347
+
Secrets: niceSecret,
348
+
})
349
+
}
350
+
351
+
func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) {
352
+
l := rp.logger.With("handler", "EditBaseSettings")
353
+
354
+
noticeId := "repo-base-settings-error"
355
+
356
+
f, err := rp.repoResolver.Resolve(r)
357
+
if err != nil {
358
+
l.Error("failed to get repo and knot", "err", err)
359
+
w.WriteHeader(http.StatusBadRequest)
360
+
return
361
+
}
362
+
363
+
client, err := rp.oauth.AuthorizedClient(r)
364
+
if err != nil {
365
+
l.Error("failed to get client")
366
+
rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.")
367
+
return
368
+
}
369
+
370
+
var (
371
+
description = r.FormValue("description")
372
+
website = r.FormValue("website")
373
+
topicStr = r.FormValue("topics")
374
+
)
375
+
376
+
err = rp.validator.ValidateURI(website)
377
+
if website != "" && err != nil {
378
+
l.Error("invalid uri", "err", err)
379
+
rp.pages.Notice(w, noticeId, err.Error())
380
+
return
381
+
}
382
+
383
+
topics, err := rp.validator.ValidateRepoTopicStr(topicStr)
384
+
if err != nil {
385
+
l.Error("invalid topics", "err", err)
386
+
rp.pages.Notice(w, noticeId, err.Error())
387
+
return
388
+
}
389
+
l.Debug("got", "topicsStr", topicStr, "topics", topics)
390
+
391
+
newRepo := f.Repo
392
+
newRepo.Description = description
393
+
newRepo.Website = website
394
+
newRepo.Topics = topics
395
+
record := newRepo.AsRecord()
396
+
397
+
tx, err := rp.db.BeginTx(r.Context(), nil)
398
+
if err != nil {
399
+
l.Error("failed to begin transaction", "err", err)
400
+
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
401
+
return
402
+
}
403
+
defer tx.Rollback()
404
+
405
+
err = db.PutRepo(tx, newRepo)
406
+
if err != nil {
407
+
l.Error("failed to update repository", "err", err)
408
+
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
409
+
return
410
+
}
411
+
412
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
413
+
if err != nil {
414
+
// failed to get record
415
+
l.Error("failed to get repo record", "err", err)
416
+
rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.")
417
+
return
418
+
}
419
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
420
+
Collection: tangled.RepoNSID,
421
+
Repo: newRepo.Did,
422
+
Rkey: newRepo.Rkey,
423
+
SwapRecord: ex.Cid,
424
+
Record: &lexutil.LexiconTypeDecoder{
425
+
Val: &record,
426
+
},
427
+
})
428
+
429
+
if err != nil {
430
+
l.Error("failed to perferom update-repo query", "err", err)
431
+
// failed to get record
432
+
rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.")
433
+
return
434
+
}
435
+
436
+
err = tx.Commit()
437
+
if err != nil {
438
+
l.Error("failed to commit", "err", err)
439
+
}
440
+
441
+
rp.pages.HxRefresh(w)
442
+
}
+106
appview/repo/tree.go
+106
appview/repo/tree.go
···
1
+
package repo
2
+
3
+
import (
4
+
"fmt"
5
+
"net/http"
6
+
"net/url"
7
+
"strings"
8
+
"time"
9
+
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/pages"
12
+
xrpcclient "tangled.org/core/appview/xrpcclient"
13
+
"tangled.org/core/types"
14
+
15
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
16
+
"github.com/go-chi/chi/v5"
17
+
"github.com/go-git/go-git/v5/plumbing"
18
+
)
19
+
20
+
func (rp *Repo) Tree(w http.ResponseWriter, r *http.Request) {
21
+
l := rp.logger.With("handler", "RepoTree")
22
+
f, err := rp.repoResolver.Resolve(r)
23
+
if err != nil {
24
+
l.Error("failed to fully resolve repo", "err", err)
25
+
return
26
+
}
27
+
ref := chi.URLParam(r, "ref")
28
+
ref, _ = url.PathUnescape(ref)
29
+
// if the tree path has a trailing slash, let's strip it
30
+
// so we don't 404
31
+
treePath := chi.URLParam(r, "*")
32
+
treePath, _ = url.PathUnescape(treePath)
33
+
treePath = strings.TrimSuffix(treePath, "/")
34
+
scheme := "http"
35
+
if !rp.config.Core.Dev {
36
+
scheme = "https"
37
+
}
38
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
39
+
xrpcc := &indigoxrpc.Client{
40
+
Host: host,
41
+
}
42
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
43
+
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
44
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
45
+
l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
46
+
rp.pages.Error503(w)
47
+
return
48
+
}
49
+
// Convert XRPC response to internal types.RepoTreeResponse
50
+
files := make([]types.NiceTree, len(xrpcResp.Files))
51
+
for i, xrpcFile := range xrpcResp.Files {
52
+
file := types.NiceTree{
53
+
Name: xrpcFile.Name,
54
+
Mode: xrpcFile.Mode,
55
+
Size: int64(xrpcFile.Size),
56
+
}
57
+
// Convert last commit info if present
58
+
if xrpcFile.Last_commit != nil {
59
+
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
60
+
file.LastCommit = &types.LastCommitInfo{
61
+
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
62
+
Message: xrpcFile.Last_commit.Message,
63
+
When: commitWhen,
64
+
}
65
+
}
66
+
files[i] = file
67
+
}
68
+
result := types.RepoTreeResponse{
69
+
Ref: xrpcResp.Ref,
70
+
Files: files,
71
+
}
72
+
if xrpcResp.Parent != nil {
73
+
result.Parent = *xrpcResp.Parent
74
+
}
75
+
if xrpcResp.Dotdot != nil {
76
+
result.DotDot = *xrpcResp.Dotdot
77
+
}
78
+
if xrpcResp.Readme != nil {
79
+
result.ReadmeFileName = xrpcResp.Readme.Filename
80
+
result.Readme = xrpcResp.Readme.Contents
81
+
}
82
+
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
83
+
// so we can safely redirect to the "parent" (which is the same file).
84
+
if len(result.Files) == 0 && result.Parent == treePath {
85
+
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
86
+
http.Redirect(w, r, redirectTo, http.StatusFound)
87
+
return
88
+
}
89
+
user := rp.oauth.GetUser(r)
90
+
var breadcrumbs [][]string
91
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
92
+
if treePath != "" {
93
+
for idx, elem := range strings.Split(treePath, "/") {
94
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
95
+
}
96
+
}
97
+
sortFiles(result.Files)
98
+
99
+
rp.pages.RepoTree(w, pages.RepoTreeParams{
100
+
LoggedInUser: user,
101
+
BreadCrumbs: breadcrumbs,
102
+
TreePath: treePath,
103
+
RepoInfo: f.RepoInfo(user),
104
+
RepoTreeResponse: result,
105
+
})
106
+
}
+1
appview/settings/settings.go
+1
appview/settings/settings.go
···
120
120
PullCommented: r.FormValue("pull_commented") == "on",
121
121
PullMerged: r.FormValue("pull_merged") == "on",
122
122
Followed: r.FormValue("followed") == "on",
123
+
UserMentioned: r.FormValue("user_mentioned") == "on",
123
124
EmailNotifications: r.FormValue("email_notifications") == "on",
124
125
}
125
126
+11
-8
appview/state/router.go
+11
-8
appview/state/router.go
···
57
57
if userutil.IsFlattenedDid(firstPart) {
58
58
unflattenedDid := userutil.UnflattenDid(firstPart)
59
59
redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/")
60
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
60
+
61
+
redirectURL := *r.URL
62
+
redirectURL.Path = "/" + redirectPath
63
+
64
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
61
65
return
62
66
}
63
67
64
68
// if using a handle with @, rewrite to work without @
65
69
if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) {
66
70
redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/")
67
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
71
+
72
+
redirectURL := *r.URL
73
+
redirectURL.Path = "/" + redirectPath
74
+
75
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
68
76
return
69
77
}
78
+
70
79
}
71
80
72
81
standardRouter.ServeHTTP(w, r)
···
81
90
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
82
91
r.Get("/", s.Profile)
83
92
r.Get("/feed.atom", s.AtomFeedPage)
84
-
85
-
// redirect /@handle/repo.git -> /@handle/repo
86
-
r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) {
87
-
nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git")
88
-
http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently)
89
-
})
90
93
91
94
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
92
95
r.Use(mw.GoImport())
+17
flake.lock
+17
flake.lock
···
1
1
{
2
2
"nodes": {
3
+
"actor-typeahead-src": {
4
+
"flake": false,
5
+
"locked": {
6
+
"lastModified": 1762835797,
7
+
"narHash": "sha256-heizoWUKDdar6ymfZTnj3ytcEv/L4d4fzSmtr0HlXsQ=",
8
+
"ref": "refs/heads/main",
9
+
"rev": "677fe7f743050a4e7f09d4a6f87bbf1325a06f6b",
10
+
"revCount": 6,
11
+
"type": "git",
12
+
"url": "https://tangled.org/@jakelazaroff.com/actor-typeahead"
13
+
},
14
+
"original": {
15
+
"type": "git",
16
+
"url": "https://tangled.org/@jakelazaroff.com/actor-typeahead"
17
+
}
18
+
},
3
19
"flake-compat": {
4
20
"flake": false,
5
21
"locked": {
···
150
166
},
151
167
"root": {
152
168
"inputs": {
169
+
"actor-typeahead-src": "actor-typeahead-src",
153
170
"flake-compat": "flake-compat",
154
171
"gomod2nix": "gomod2nix",
155
172
"htmx-src": "htmx-src",
+6
-1
flake.nix
+6
-1
flake.nix
···
33
33
url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip";
34
34
flake = false;
35
35
};
36
+
actor-typeahead-src = {
37
+
url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead";
38
+
flake = false;
39
+
};
36
40
ibm-plex-mono-src = {
37
41
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
38
42
flake = false;
···
54
58
inter-fonts-src,
55
59
sqlite-lib-src,
56
60
ibm-plex-mono-src,
61
+
actor-typeahead-src,
57
62
...
58
63
}: let
59
64
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
···
81
86
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
82
87
goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;};
83
88
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
84
-
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
89
+
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
85
90
};
86
91
appview = self.callPackage ./nix/pkgs/appview.nix {};
87
92
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
+4
-12
go.mod
+4
-12
go.mod
···
7
7
github.com/alecthomas/assert/v2 v2.11.0
8
8
github.com/alecthomas/chroma/v2 v2.15.0
9
9
github.com/avast/retry-go/v4 v4.6.1
10
+
github.com/blevesearch/bleve/v2 v2.5.3
10
11
github.com/bluekeyes/go-gitdiff v0.8.1
11
12
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
12
13
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
14
+
github.com/bmatcuk/doublestar/v4 v4.9.1
13
15
github.com/carlmjohnson/versioninfo v0.22.5
14
16
github.com/casbin/casbin/v2 v2.103.0
17
+
github.com/charmbracelet/log v0.4.2
15
18
github.com/cloudflare/cloudflare-go v0.115.0
16
19
github.com/cyphar/filepath-securejoin v0.4.1
17
20
github.com/dgraph-io/ristretto v0.2.0
···
29
32
github.com/hiddeco/sshsig v0.2.0
30
33
github.com/hpcloud/tail v1.0.0
31
34
github.com/ipfs/go-cid v0.5.0
32
-
github.com/lestrrat-go/jwx/v2 v2.1.6
33
35
github.com/mattn/go-sqlite3 v1.14.24
34
36
github.com/microcosm-cc/bluemonday v1.0.27
35
37
github.com/openbao/openbao/api/v2 v2.3.0
···
45
47
github.com/wyatt915/goldmark-treeblood v0.0.1
46
48
github.com/yuin/goldmark v1.7.13
47
49
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
50
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
48
51
golang.org/x/crypto v0.40.0
49
52
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
50
53
golang.org/x/image v0.31.0
···
65
68
github.com/aymerick/douceur v0.2.0 // indirect
66
69
github.com/beorn7/perks v1.0.1 // indirect
67
70
github.com/bits-and-blooms/bitset v1.22.0 // indirect
68
-
github.com/blevesearch/bleve/v2 v2.5.3 // indirect
69
71
github.com/blevesearch/bleve_index_api v1.2.8 // indirect
70
72
github.com/blevesearch/geo v0.2.4 // indirect
71
73
github.com/blevesearch/go-faiss v1.0.25 // indirect
···
83
85
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
84
86
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
85
87
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
86
-
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
87
88
github.com/casbin/govaluate v1.3.0 // indirect
88
89
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
89
90
github.com/cespare/xxhash/v2 v2.3.0 // indirect
90
91
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
91
92
github.com/charmbracelet/lipgloss v1.1.0 // indirect
92
-
github.com/charmbracelet/log v0.4.2 // indirect
93
93
github.com/charmbracelet/x/ansi v0.8.0 // indirect
94
94
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
95
95
github.com/charmbracelet/x/term v0.2.1 // indirect
···
98
98
github.com/containerd/errdefs/pkg v0.3.0 // indirect
99
99
github.com/containerd/log v0.1.0 // indirect
100
100
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
101
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
102
101
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
103
102
github.com/distribution/reference v0.6.0 // indirect
104
103
github.com/dlclark/regexp2 v1.11.5 // indirect
···
152
151
github.com/kevinburke/ssh_config v1.2.0 // indirect
153
152
github.com/klauspost/compress v1.18.0 // indirect
154
153
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
155
-
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
156
-
github.com/lestrrat-go/httpcc v1.0.1 // indirect
157
-
github.com/lestrrat-go/httprc v1.0.6 // indirect
158
-
github.com/lestrrat-go/iter v1.0.2 // indirect
159
-
github.com/lestrrat-go/option v1.0.1 // indirect
160
154
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
161
155
github.com/mattn/go-isatty v0.0.20 // indirect
162
156
github.com/mattn/go-runewidth v0.0.16 // indirect
···
191
185
github.com/prometheus/procfs v0.16.1 // indirect
192
186
github.com/rivo/uniseg v0.4.7 // indirect
193
187
github.com/ryanuber/go-glob v1.0.0 // indirect
194
-
github.com/segmentio/asm v1.2.0 // indirect
195
188
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
196
189
github.com/spaolacci/murmur3 v1.1.0 // indirect
197
190
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
···
199
192
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
200
193
github.com/wyatt915/treeblood v0.1.16 // indirect
201
194
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
202
-
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect
203
195
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
204
196
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
205
197
go.etcd.io/bbolt v1.4.0 // indirect
-17
go.sum
-17
go.sum
···
71
71
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
72
72
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
73
73
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
74
-
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
75
74
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
76
75
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
77
76
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
126
125
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
127
126
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
128
127
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
129
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
130
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
131
128
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
132
129
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
133
130
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
···
330
327
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
331
328
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
332
329
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
333
-
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
334
-
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
335
-
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
336
-
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
337
-
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
338
-
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
339
-
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
340
-
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
341
-
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
342
-
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
343
-
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
344
-
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
345
330
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
346
331
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
347
332
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
···
466
451
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
467
452
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
468
453
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
469
-
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
470
-
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
471
454
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
472
455
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
473
456
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
+38
input.css
+38
input.css
···
161
161
@apply no-underline;
162
162
}
163
163
164
+
.prose a.mention {
165
+
@apply no-underline hover:underline;
166
+
}
167
+
164
168
.prose li {
165
169
@apply my-0 py-0;
166
170
}
···
241
245
details[data-callout] > summary::-webkit-details-marker {
242
246
display: none;
243
247
}
248
+
244
249
}
245
250
@layer utilities {
246
251
.error {
···
924
929
text-decoration: underline;
925
930
}
926
931
}
932
+
933
+
actor-typeahead {
934
+
--color-background: #ffffff;
935
+
--color-border: #d1d5db;
936
+
--color-shadow: #000000;
937
+
--color-hover: #f9fafb;
938
+
--color-avatar-fallback: #e5e7eb;
939
+
--radius: 0.0;
940
+
--padding-menu: 0.0rem;
941
+
z-index: 1000;
942
+
}
943
+
944
+
actor-typeahead::part(handle) {
945
+
color: #111827;
946
+
}
947
+
948
+
actor-typeahead::part(menu) {
949
+
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
950
+
}
951
+
952
+
@media (prefers-color-scheme: dark) {
953
+
actor-typeahead {
954
+
--color-background: #1f2937;
955
+
--color-border: #4b5563;
956
+
--color-shadow: #000000;
957
+
--color-hover: #374151;
958
+
--color-avatar-fallback: #4b5563;
959
+
}
960
+
961
+
actor-typeahead::part(handle) {
962
+
color: #f9fafb;
963
+
}
964
+
}
+60
-2
knotserver/git/git.go
+60
-2
knotserver/git/git.go
···
3
3
import (
4
4
"archive/tar"
5
5
"bytes"
6
+
"errors"
6
7
"fmt"
7
8
"io"
8
9
"io/fs"
···
12
13
"time"
13
14
14
15
"github.com/go-git/go-git/v5"
16
+
"github.com/go-git/go-git/v5/config"
15
17
"github.com/go-git/go-git/v5/plumbing"
16
18
"github.com/go-git/go-git/v5/plumbing/object"
17
19
)
18
20
19
21
var (
20
-
ErrBinaryFile = fmt.Errorf("binary file")
21
-
ErrNotBinaryFile = fmt.Errorf("not binary file")
22
+
ErrBinaryFile = errors.New("binary file")
23
+
ErrNotBinaryFile = errors.New("not binary file")
24
+
ErrMissingGitModules = errors.New("no .gitmodules file found")
25
+
ErrInvalidGitModules = errors.New("invalid .gitmodules file")
26
+
ErrNotSubmodule = errors.New("path is not a submodule")
22
27
)
23
28
24
29
type GitRepo struct {
···
188
193
defer reader.Close()
189
194
190
195
return io.ReadAll(reader)
196
+
}
197
+
198
+
// read and parse .gitmodules
199
+
func (g *GitRepo) Submodules() (*config.Modules, error) {
200
+
c, err := g.r.CommitObject(g.h)
201
+
if err != nil {
202
+
return nil, fmt.Errorf("commit object: %w", err)
203
+
}
204
+
205
+
tree, err := c.Tree()
206
+
if err != nil {
207
+
return nil, fmt.Errorf("tree: %w", err)
208
+
}
209
+
210
+
// read .gitmodules file
211
+
modulesEntry, err := tree.FindEntry(".gitmodules")
212
+
if err != nil {
213
+
return nil, fmt.Errorf("%w: %w", ErrMissingGitModules, err)
214
+
}
215
+
216
+
modulesFile, err := tree.TreeEntryFile(modulesEntry)
217
+
if err != nil {
218
+
return nil, fmt.Errorf("%w: failed to read file: %w", ErrInvalidGitModules, err)
219
+
}
220
+
221
+
content, err := modulesFile.Contents()
222
+
if err != nil {
223
+
return nil, fmt.Errorf("%w: failed to read contents: %w", ErrInvalidGitModules, err)
224
+
}
225
+
226
+
// parse .gitmodules
227
+
modules := config.NewModules()
228
+
if err = modules.Unmarshal([]byte(content)); err != nil {
229
+
return nil, fmt.Errorf("%w: failed to parse: %w", ErrInvalidGitModules, err)
230
+
}
231
+
232
+
return modules, nil
233
+
}
234
+
235
+
func (g *GitRepo) Submodule(path string) (*config.Submodule, error) {
236
+
modules, err := g.Submodules()
237
+
if err != nil {
238
+
return nil, err
239
+
}
240
+
241
+
for _, submodule := range modules.Submodules {
242
+
if submodule.Path == path {
243
+
return submodule, nil
244
+
}
245
+
}
246
+
247
+
// path is not a submodule
248
+
return nil, ErrNotSubmodule
191
249
}
192
250
193
251
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
+4
-13
knotserver/git/tree.go
+4
-13
knotserver/git/tree.go
···
7
7
"path"
8
8
"time"
9
9
10
+
"github.com/go-git/go-git/v5/plumbing/filemode"
10
11
"github.com/go-git/go-git/v5/plumbing/object"
11
12
"tangled.org/core/types"
12
13
)
···
53
54
}
54
55
55
56
for _, e := range subtree.Entries {
56
-
mode, _ := e.Mode.ToOSFileMode()
57
57
sz, _ := subtree.Size(e.Name)
58
-
59
58
fpath := path.Join(parent, e.Name)
60
59
61
60
var lastCommit *types.LastCommitInfo
···
69
68
70
69
nts = append(nts, types.NiceTree{
71
70
Name: e.Name,
72
-
Mode: mode.String(),
73
-
IsFile: e.Mode.IsFile(),
71
+
Mode: e.Mode.String(),
74
72
Size: sz,
75
73
LastCommit: lastCommit,
76
74
})
···
126
124
default:
127
125
}
128
126
129
-
mode, err := e.Mode.ToOSFileMode()
130
-
if err != nil {
131
-
// TODO: log this
132
-
continue
133
-
}
134
-
135
127
if e.Mode.IsFile() {
136
-
err = cb(e, currentTree, root)
137
-
if errors.Is(err, TerminateWalk) {
128
+
if err := cb(e, currentTree, root); errors.Is(err, TerminateWalk) {
138
129
return err
139
130
}
140
131
}
141
132
142
133
// e is a directory
143
-
if mode.IsDir() {
134
+
if e.Mode == filemode.Dir {
144
135
subtree, err := currentTree.Tree(e.Name)
145
136
if err != nil {
146
137
return fmt.Errorf("sub tree %s: %w", e.Name, err)
+1
-1
knotserver/ingester.go
+1
-1
knotserver/ingester.go
+1
-1
knotserver/internal.go
+1
-1
knotserver/internal.go
+21
-2
knotserver/xrpc/repo_blob.go
+21
-2
knotserver/xrpc/repo_blob.go
···
42
42
return
43
43
}
44
44
45
+
// first check if this path is a submodule
46
+
submodule, err := gr.Submodule(treePath)
47
+
if err != nil {
48
+
// this is okay, continue and try to treat it as a regular file
49
+
} else {
50
+
response := tangled.RepoBlob_Output{
51
+
Ref: ref,
52
+
Path: treePath,
53
+
Submodule: &tangled.RepoBlob_Submodule{
54
+
Name: submodule.Name,
55
+
Url: submodule.URL,
56
+
Branch: &submodule.Branch,
57
+
},
58
+
}
59
+
writeJson(w, response)
60
+
return
61
+
}
62
+
45
63
contents, err := gr.RawContent(treePath)
46
64
if err != nil {
47
65
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
···
101
119
var encoding string
102
120
103
121
isBinary := !isTextual(mimeType)
122
+
size := int64(len(contents))
104
123
105
124
if isBinary {
106
125
content = base64.StdEncoding.EncodeToString(contents)
···
113
132
response := tangled.RepoBlob_Output{
114
133
Ref: ref,
115
134
Path: treePath,
116
-
Content: content,
135
+
Content: &content,
117
136
Encoding: &encoding,
118
-
Size: &[]int64{int64(len(contents))}[0],
137
+
Size: &size,
119
138
IsBinary: &isBinary,
120
139
}
121
140
+3
-5
knotserver/xrpc/repo_tree.go
+3
-5
knotserver/xrpc/repo_tree.go
···
67
67
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
68
68
for i, file := range files {
69
69
entry := &tangled.RepoTree_TreeEntry{
70
-
Name: file.Name,
71
-
Mode: file.Mode,
72
-
Size: file.Size,
73
-
Is_file: file.IsFile,
74
-
Is_subtree: file.IsSubtree,
70
+
Name: file.Name,
71
+
Mode: file.Mode,
72
+
Size: file.Size,
75
73
}
76
74
77
75
if file.LastCommit != nil {
+49
-5
lexicons/repo/blob.json
+49
-5
lexicons/repo/blob.json
···
6
6
"type": "query",
7
7
"parameters": {
8
8
"type": "params",
9
-
"required": ["repo", "ref", "path"],
9
+
"required": [
10
+
"repo",
11
+
"ref",
12
+
"path"
13
+
],
10
14
"properties": {
11
15
"repo": {
12
16
"type": "string",
···
31
35
"encoding": "application/json",
32
36
"schema": {
33
37
"type": "object",
34
-
"required": ["ref", "path", "content"],
38
+
"required": [
39
+
"ref",
40
+
"path"
41
+
],
35
42
"properties": {
36
43
"ref": {
37
44
"type": "string",
···
48
55
"encoding": {
49
56
"type": "string",
50
57
"description": "Content encoding",
51
-
"enum": ["utf-8", "base64"]
58
+
"enum": [
59
+
"utf-8",
60
+
"base64"
61
+
]
52
62
},
53
63
"size": {
54
64
"type": "integer",
···
61
71
"mimeType": {
62
72
"type": "string",
63
73
"description": "MIME type of the file"
74
+
},
75
+
"submodule": {
76
+
"type": "ref",
77
+
"ref": "#submodule",
78
+
"description": "Submodule information if path is a submodule"
64
79
},
65
80
"lastCommit": {
66
81
"type": "ref",
···
90
105
},
91
106
"lastCommit": {
92
107
"type": "object",
93
-
"required": ["hash", "message", "when"],
108
+
"required": [
109
+
"hash",
110
+
"message",
111
+
"when"
112
+
],
94
113
"properties": {
95
114
"hash": {
96
115
"type": "string",
···
117
136
},
118
137
"signature": {
119
138
"type": "object",
120
-
"required": ["name", "email", "when"],
139
+
"required": [
140
+
"name",
141
+
"email",
142
+
"when"
143
+
],
121
144
"properties": {
122
145
"name": {
123
146
"type": "string",
···
131
154
"type": "string",
132
155
"format": "datetime",
133
156
"description": "Author timestamp"
157
+
}
158
+
}
159
+
},
160
+
"submodule": {
161
+
"type": "object",
162
+
"required": [
163
+
"name",
164
+
"url"
165
+
],
166
+
"properties": {
167
+
"name": {
168
+
"type": "string",
169
+
"description": "Submodule name"
170
+
},
171
+
"url": {
172
+
"type": "string",
173
+
"description": "Submodule repository URL"
174
+
},
175
+
"branch": {
176
+
"type": "string",
177
+
"description": "Branch to track in the submodule"
134
178
}
135
179
}
136
180
}
+1
-9
lexicons/repo/tree.json
+1
-9
lexicons/repo/tree.json
···
91
91
},
92
92
"treeEntry": {
93
93
"type": "object",
94
-
"required": ["name", "mode", "size", "is_file", "is_subtree"],
94
+
"required": ["name", "mode", "size"],
95
95
"properties": {
96
96
"name": {
97
97
"type": "string",
···
104
104
"size": {
105
105
"type": "integer",
106
106
"description": "File size in bytes"
107
-
},
108
-
"is_file": {
109
-
"type": "boolean",
110
-
"description": "Whether this entry is a file"
111
-
},
112
-
"is_subtree": {
113
-
"type": "boolean",
114
-
"description": "Whether this entry is a directory/subtree"
115
107
},
116
108
"last_commit": {
117
109
"type": "ref",
+278
-12
nix/modules/appview.nix
+278
-12
nix/modules/appview.nix
···
13
13
default = false;
14
14
description = "Enable tangled appview";
15
15
};
16
+
16
17
package = mkOption {
17
18
type = types.package;
18
19
description = "Package to use for the appview";
19
20
};
21
+
22
+
# core configuration
20
23
port = mkOption {
21
-
type = types.int;
24
+
type = types.port;
22
25
default = 3000;
23
26
description = "Port to run the appview on";
24
27
};
28
+
29
+
listenAddr = mkOption {
30
+
type = types.str;
31
+
default = "0.0.0.0:${toString cfg.port}";
32
+
description = "Listen address for the appview service";
33
+
};
34
+
35
+
dbPath = mkOption {
36
+
type = types.str;
37
+
default = "/var/lib/appview/appview.db";
38
+
description = "Path to the SQLite database file";
39
+
};
40
+
41
+
appviewHost = mkOption {
42
+
type = types.str;
43
+
default = "https://tangled.org";
44
+
example = "https://example.com";
45
+
description = "Public host URL for the appview instance";
46
+
};
47
+
48
+
appviewName = mkOption {
49
+
type = types.str;
50
+
default = "Tangled";
51
+
description = "Display name for the appview instance";
52
+
};
53
+
54
+
dev = mkOption {
55
+
type = types.bool;
56
+
default = false;
57
+
description = "Enable development mode";
58
+
};
59
+
60
+
disallowedNicknamesFile = mkOption {
61
+
type = types.nullOr types.path;
62
+
default = null;
63
+
description = "Path to file containing disallowed nicknames";
64
+
};
65
+
66
+
# redis configuration
67
+
redis = {
68
+
addr = mkOption {
69
+
type = types.str;
70
+
default = "localhost:6379";
71
+
description = "Redis server address";
72
+
};
73
+
74
+
db = mkOption {
75
+
type = types.int;
76
+
default = 0;
77
+
description = "Redis database number";
78
+
};
79
+
};
80
+
81
+
# jetstream configuration
82
+
jetstream = {
83
+
endpoint = mkOption {
84
+
type = types.str;
85
+
default = "wss://jetstream1.us-east.bsky.network/subscribe";
86
+
description = "Jetstream WebSocket endpoint";
87
+
};
88
+
};
89
+
90
+
# knotstream consumer configuration
91
+
knotstream = {
92
+
retryInterval = mkOption {
93
+
type = types.str;
94
+
default = "60s";
95
+
description = "Initial retry interval for knotstream consumer";
96
+
};
97
+
98
+
maxRetryInterval = mkOption {
99
+
type = types.str;
100
+
default = "120m";
101
+
description = "Maximum retry interval for knotstream consumer";
102
+
};
103
+
104
+
connectionTimeout = mkOption {
105
+
type = types.str;
106
+
default = "5s";
107
+
description = "Connection timeout for knotstream consumer";
108
+
};
109
+
110
+
workerCount = mkOption {
111
+
type = types.int;
112
+
default = 64;
113
+
description = "Number of workers for knotstream consumer";
114
+
};
115
+
116
+
queueSize = mkOption {
117
+
type = types.int;
118
+
default = 100;
119
+
description = "Queue size for knotstream consumer";
120
+
};
121
+
};
122
+
123
+
# spindlestream consumer configuration
124
+
spindlestream = {
125
+
retryInterval = mkOption {
126
+
type = types.str;
127
+
default = "60s";
128
+
description = "Initial retry interval for spindlestream consumer";
129
+
};
130
+
131
+
maxRetryInterval = mkOption {
132
+
type = types.str;
133
+
default = "120m";
134
+
description = "Maximum retry interval for spindlestream consumer";
135
+
};
136
+
137
+
connectionTimeout = mkOption {
138
+
type = types.str;
139
+
default = "5s";
140
+
description = "Connection timeout for spindlestream consumer";
141
+
};
142
+
143
+
workerCount = mkOption {
144
+
type = types.int;
145
+
default = 64;
146
+
description = "Number of workers for spindlestream consumer";
147
+
};
148
+
149
+
queueSize = mkOption {
150
+
type = types.int;
151
+
default = 100;
152
+
description = "Queue size for spindlestream consumer";
153
+
};
154
+
};
155
+
156
+
# resend configuration
157
+
resend = {
158
+
sentFrom = mkOption {
159
+
type = types.str;
160
+
default = "noreply@notifs.tangled.sh";
161
+
description = "Email address to send notifications from";
162
+
};
163
+
};
164
+
165
+
# posthog configuration
166
+
posthog = {
167
+
endpoint = mkOption {
168
+
type = types.str;
169
+
default = "https://eu.i.posthog.com";
170
+
description = "PostHog API endpoint";
171
+
};
172
+
};
173
+
174
+
# camo configuration
175
+
camo = {
176
+
host = mkOption {
177
+
type = types.str;
178
+
default = "https://camo.tangled.sh";
179
+
description = "Camo proxy host URL";
180
+
};
181
+
};
182
+
183
+
# avatar configuration
184
+
avatar = {
185
+
host = mkOption {
186
+
type = types.str;
187
+
default = "https://avatar.tangled.sh";
188
+
description = "Avatar service host URL";
189
+
};
190
+
};
191
+
192
+
plc = {
193
+
url = mkOption {
194
+
type = types.str;
195
+
default = "https://plc.directory";
196
+
description = "PLC directory URL";
197
+
};
198
+
};
199
+
200
+
pds = {
201
+
host = mkOption {
202
+
type = types.str;
203
+
default = "https://tngl.sh";
204
+
description = "PDS host URL";
205
+
};
206
+
};
207
+
208
+
label = {
209
+
defaults = mkOption {
210
+
type = types.listOf types.str;
211
+
default = [
212
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix"
213
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"
214
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate"
215
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation"
216
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"
217
+
];
218
+
description = "Default label definitions";
219
+
};
220
+
221
+
goodFirstIssue = mkOption {
222
+
type = types.str;
223
+
default = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue";
224
+
description = "Good first issue label definition";
225
+
};
226
+
};
227
+
25
228
environmentFile = mkOption {
26
229
type = with types; nullOr path;
27
230
default = null;
28
-
example = "/etc-/appview.env";
231
+
example = "/etc/appview.env";
29
232
description = ''
30
233
Additional environment file as defined in {manpage}`systemd.exec(5)`.
31
234
32
-
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
33
-
passed to the service without makeing them world readable in the
34
-
nix store.
35
-
235
+
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET`,
236
+
{env}`TANGLED_OAUTH_CLIENT_SECRET`, {env}`TANGLED_RESEND_API_KEY`,
237
+
{env}`TANGLED_CAMO_SHARED_SECRET`, {env}`TANGLED_AVATAR_SHARED_SECRET`,
238
+
{env}`TANGLED_REDIS_PASS`, {env}`TANGLED_PDS_ADMIN_SECRET`,
239
+
{env}`TANGLED_CLOUDFLARE_API_TOKEN`, {env}`TANGLED_CLOUDFLARE_ZONE_ID`,
240
+
{env}`TANGLED_CLOUDFLARE_TURNSTILE_SITE_KEY`,
241
+
{env}`TANGLED_CLOUDFLARE_TURNSTILE_SECRET_KEY`,
242
+
{env}`TANGLED_POSTHOG_API_KEY`, {env}`TANGLED_APP_PASSWORD`,
243
+
and {env}`TANGLED_ALT_APP_PASSWORD` may be passed to the service
244
+
without making them world readable in the nix store.
36
245
'';
37
246
};
38
247
};
···
47
256
systemd.services.appview = {
48
257
description = "tangled appview service";
49
258
wantedBy = ["multi-user.target"];
50
-
after = ["redis-appview.service"];
259
+
after = ["redis-appview.service" "network-online.target"];
51
260
requires = ["redis-appview.service"];
261
+
wants = ["network-online.target"];
52
262
53
263
serviceConfig = {
54
-
ListenStream = "0.0.0.0:${toString cfg.port}";
264
+
Type = "simple";
55
265
ExecStart = "${cfg.package}/bin/appview";
56
266
Restart = "always";
57
-
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
58
-
};
267
+
RestartSec = "10s";
268
+
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
269
+
270
+
# state directory
271
+
StateDirectory = "appview";
272
+
WorkingDirectory = "/var/lib/appview";
59
273
60
-
environment = {
61
-
TANGLED_DB_PATH = "appview.db";
274
+
# security hardening
275
+
NoNewPrivileges = true;
276
+
PrivateTmp = true;
277
+
ProtectSystem = "strict";
278
+
ProtectHome = true;
279
+
ReadWritePaths = ["/var/lib/appview"];
62
280
};
281
+
282
+
environment =
283
+
{
284
+
TANGLED_DB_PATH = cfg.dbPath;
285
+
TANGLED_LISTEN_ADDR = cfg.listenAddr;
286
+
TANGLED_APPVIEW_HOST = cfg.appviewHost;
287
+
TANGLED_APPVIEW_NAME = cfg.appviewName;
288
+
TANGLED_DEV =
289
+
if cfg.dev
290
+
then "true"
291
+
else "false";
292
+
}
293
+
// optionalAttrs (cfg.disallowedNicknamesFile != null) {
294
+
TANGLED_DISALLOWED_NICKNAMES_FILE = cfg.disallowedNicknamesFile;
295
+
}
296
+
// {
297
+
TANGLED_REDIS_ADDR = cfg.redis.addr;
298
+
TANGLED_REDIS_DB = toString cfg.redis.db;
299
+
300
+
TANGLED_JETSTREAM_ENDPOINT = cfg.jetstream.endpoint;
301
+
302
+
TANGLED_KNOTSTREAM_RETRY_INTERVAL = cfg.knotstream.retryInterval;
303
+
TANGLED_KNOTSTREAM_MAX_RETRY_INTERVAL = cfg.knotstream.maxRetryInterval;
304
+
TANGLED_KNOTSTREAM_CONNECTION_TIMEOUT = cfg.knotstream.connectionTimeout;
305
+
TANGLED_KNOTSTREAM_WORKER_COUNT = toString cfg.knotstream.workerCount;
306
+
TANGLED_KNOTSTREAM_QUEUE_SIZE = toString cfg.knotstream.queueSize;
307
+
308
+
TANGLED_SPINDLESTREAM_RETRY_INTERVAL = cfg.spindlestream.retryInterval;
309
+
TANGLED_SPINDLESTREAM_MAX_RETRY_INTERVAL = cfg.spindlestream.maxRetryInterval;
310
+
TANGLED_SPINDLESTREAM_CONNECTION_TIMEOUT = cfg.spindlestream.connectionTimeout;
311
+
TANGLED_SPINDLESTREAM_WORKER_COUNT = toString cfg.spindlestream.workerCount;
312
+
TANGLED_SPINDLESTREAM_QUEUE_SIZE = toString cfg.spindlestream.queueSize;
313
+
314
+
TANGLED_RESEND_SENT_FROM = cfg.resend.sentFrom;
315
+
316
+
TANGLED_POSTHOG_ENDPOINT = cfg.posthog.endpoint;
317
+
318
+
TANGLED_CAMO_HOST = cfg.camo.host;
319
+
320
+
TANGLED_AVATAR_HOST = cfg.avatar.host;
321
+
322
+
TANGLED_PLC_URL = cfg.plc.url;
323
+
324
+
TANGLED_PDS_HOST = cfg.pds.host;
325
+
326
+
TANGLED_LABEL_DEFAULTS = concatStringsSep "," cfg.label.defaults;
327
+
TANGLED_LABEL_GFI = cfg.label.goodFirstIssue;
328
+
};
63
329
};
64
330
};
65
331
}
+58
-2
nix/modules/knot.nix
+58
-2
nix/modules/knot.nix
···
51
51
description = "Path where repositories are scanned from";
52
52
};
53
53
54
+
readme = mkOption {
55
+
type = types.listOf types.str;
56
+
default = [
57
+
"README.md"
58
+
"readme.md"
59
+
"README"
60
+
"readme"
61
+
"README.markdown"
62
+
"readme.markdown"
63
+
"README.txt"
64
+
"readme.txt"
65
+
"README.rst"
66
+
"readme.rst"
67
+
"README.org"
68
+
"readme.org"
69
+
"README.asciidoc"
70
+
"readme.asciidoc"
71
+
];
72
+
description = "List of README filenames to look for (in priority order)";
73
+
};
74
+
54
75
mainBranch = mkOption {
55
76
type = types.str;
56
77
default = "main";
···
58
79
};
59
80
};
60
81
82
+
git = {
83
+
userName = mkOption {
84
+
type = types.str;
85
+
default = "Tangled";
86
+
description = "Git user name used as committer";
87
+
};
88
+
89
+
userEmail = mkOption {
90
+
type = types.str;
91
+
default = "noreply@tangled.org";
92
+
description = "Git user email used as committer";
93
+
};
94
+
};
95
+
61
96
motd = mkOption {
62
97
type = types.nullOr types.str;
63
98
default = null;
···
123
158
description = "Jetstream endpoint to subscribe to";
124
159
};
125
160
161
+
logDids = mkOption {
162
+
type = types.bool;
163
+
default = true;
164
+
description = "Enable logging of DIDs";
165
+
};
166
+
126
167
dev = mkOption {
127
168
type = types.bool;
128
169
default = false;
···
190
231
mkdir -p "${cfg.stateDir}/.config/git"
191
232
cat > "${cfg.stateDir}/.config/git/config" << EOF
192
233
[user]
193
-
name = Git User
194
-
email = git@example.com
234
+
name = ${cfg.git.userName}
235
+
email = ${cfg.git.userEmail}
195
236
[receive]
196
237
advertisePushOptions = true
238
+
[uploadpack]
239
+
allowFilter = true
197
240
EOF
198
241
${setMotd}
199
242
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
···
205
248
WorkingDirectory = cfg.stateDir;
206
249
Environment = [
207
250
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
251
+
"KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}"
208
252
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
253
+
"KNOT_GIT_USER_NAME=${cfg.git.userName}"
254
+
"KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}"
209
255
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
210
256
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
211
257
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
···
214
260
"KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}"
215
261
"KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
216
262
"KNOT_SERVER_OWNER=${cfg.server.owner}"
263
+
"KNOT_SERVER_LOG_DIDS=${
264
+
if cfg.server.logDids
265
+
then "true"
266
+
else "false"
267
+
}"
268
+
"KNOT_SERVER_DEV=${
269
+
if cfg.server.dev
270
+
then "true"
271
+
else "false"
272
+
}"
217
273
];
218
274
ExecStart = "${cfg.package}/bin/knot server";
219
275
Restart = "always";
+2
nix/pkgs/appview-static-files.nix
+2
nix/pkgs/appview-static-files.nix
···
5
5
lucide-src,
6
6
inter-fonts-src,
7
7
ibm-plex-mono-src,
8
+
actor-typeahead-src,
8
9
sqlite-lib,
9
10
tailwindcss,
10
11
src,
···
24
25
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
25
26
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
26
27
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
28
+
cp -f ${actor-typeahead-src}/actor-typeahead.js .
27
29
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
28
30
# for whatever reason (produces broken css), so we are doing this instead
29
31
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+1
-1
nix/vm.nix
+1
-1
nix/vm.nix
···
97
97
enable = true;
98
98
server = {
99
99
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
100
-
hostname = envVarOr "TANGLED_VM_SPINDLE_OWNER" "localhost:6555";
100
+
hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
101
101
plcUrl = plcUrl;
102
102
jetstreamEndpoint = jetstream;
103
103
listenAddr = "0.0.0.0:6555";
+1
-1
spindle/config/config.go
+1
-1
spindle/config/config.go
···
13
13
DBPath string `env:"DB_PATH, default=spindle.db"`
14
14
Hostname string `env:"HOSTNAME, required"`
15
15
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
16
-
PlcUrl string `env:"PLC_URL, default=plc.directory"`
16
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
17
17
Dev bool `env:"DEV, default=false"`
18
18
Owner string `env:"OWNER, required"`
19
19
Secrets Secrets `env:",prefix=SECRETS_"`
+85
-40
spindle/server.go
+85
-40
spindle/server.go
···
49
49
vault secrets.Manager
50
50
}
51
51
52
-
func Run(ctx context.Context) error {
52
+
// New creates a new Spindle server with the provided configuration and engines.
53
+
func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) {
53
54
logger := log.FromContext(ctx)
54
-
55
-
cfg, err := config.Load(ctx)
56
-
if err != nil {
57
-
return fmt.Errorf("failed to load config: %w", err)
58
-
}
59
55
60
56
d, err := db.Make(cfg.Server.DBPath)
61
57
if err != nil {
62
-
return fmt.Errorf("failed to setup db: %w", err)
58
+
return nil, fmt.Errorf("failed to setup db: %w", err)
63
59
}
64
60
65
61
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
66
62
if err != nil {
67
-
return fmt.Errorf("failed to setup rbac enforcer: %w", err)
63
+
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
68
64
}
69
65
e.E.EnableAutoSave(true)
70
66
···
74
70
switch cfg.Server.Secrets.Provider {
75
71
case "openbao":
76
72
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
77
-
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
73
+
return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
78
74
}
79
75
vault, err = secrets.NewOpenBaoManager(
80
76
cfg.Server.Secrets.OpenBao.ProxyAddr,
···
82
78
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
83
79
)
84
80
if err != nil {
85
-
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
81
+
return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err)
86
82
}
87
83
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
88
84
case "sqlite", "":
89
85
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
90
86
if err != nil {
91
-
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
87
+
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
92
88
}
93
89
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
94
90
default:
95
-
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
96
-
}
97
-
98
-
nixeryEng, err := nixery.New(ctx, cfg)
99
-
if err != nil {
100
-
return err
91
+
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
101
92
}
102
93
103
94
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
···
110
101
}
111
102
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
112
103
if err != nil {
113
-
return fmt.Errorf("failed to setup jetstream client: %w", err)
104
+
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
114
105
}
115
106
jc.AddDid(cfg.Server.Owner)
116
107
117
108
// Check if the spindle knows about any Dids;
118
109
dids, err := d.GetAllDids()
119
110
if err != nil {
120
-
return fmt.Errorf("failed to get all dids: %w", err)
111
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
121
112
}
122
113
for _, d := range dids {
123
114
jc.AddDid(d)
···
125
116
126
117
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
127
118
128
-
spindle := Spindle{
119
+
spindle := &Spindle{
129
120
jc: jc,
130
121
e: e,
131
122
db: d,
132
123
l: logger,
133
124
n: &n,
134
-
engs: map[string]models.Engine{"nixery": nixeryEng},
125
+
engs: engines,
135
126
jq: jq,
136
127
cfg: cfg,
137
128
res: resolver,
···
140
131
141
132
err = e.AddSpindle(rbacDomain)
142
133
if err != nil {
143
-
return fmt.Errorf("failed to set rbac domain: %w", err)
134
+
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
144
135
}
145
136
err = spindle.configureOwner()
146
137
if err != nil {
147
-
return err
138
+
return nil, err
148
139
}
149
140
logger.Info("owner set", "did", cfg.Server.Owner)
150
141
151
-
// starts a job queue runner in the background
152
-
jq.Start()
153
-
defer jq.Stop()
154
-
155
-
// Stop vault token renewal if it implements Stopper
156
-
if stopper, ok := vault.(secrets.Stopper); ok {
157
-
defer stopper.Stop()
158
-
}
159
-
160
142
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
161
143
if err != nil {
162
-
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
144
+
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
163
145
}
164
146
165
147
err = jc.StartJetstream(ctx, spindle.ingest())
166
148
if err != nil {
167
-
return fmt.Errorf("failed to start jetstream consumer: %w", err)
149
+
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
168
150
}
169
151
170
152
// for each incoming sh.tangled.pipeline, we execute
···
177
159
ccfg.CursorStore = cursorStore
178
160
knownKnots, err := d.Knots()
179
161
if err != nil {
180
-
return err
162
+
return nil, err
181
163
}
182
164
for _, knot := range knownKnots {
183
165
logger.Info("adding source start", "knot", knot)
···
185
167
}
186
168
spindle.ks = eventconsumer.NewConsumer(*ccfg)
187
169
170
+
return spindle, nil
171
+
}
172
+
173
+
// DB returns the database instance.
174
+
func (s *Spindle) DB() *db.DB {
175
+
return s.db
176
+
}
177
+
178
+
// Queue returns the job queue instance.
179
+
func (s *Spindle) Queue() *queue.Queue {
180
+
return s.jq
181
+
}
182
+
183
+
// Engines returns the map of available engines.
184
+
func (s *Spindle) Engines() map[string]models.Engine {
185
+
return s.engs
186
+
}
187
+
188
+
// Vault returns the secrets manager instance.
189
+
func (s *Spindle) Vault() secrets.Manager {
190
+
return s.vault
191
+
}
192
+
193
+
// Notifier returns the notifier instance.
194
+
func (s *Spindle) Notifier() *notifier.Notifier {
195
+
return s.n
196
+
}
197
+
198
+
// Enforcer returns the RBAC enforcer instance.
199
+
func (s *Spindle) Enforcer() *rbac.Enforcer {
200
+
return s.e
201
+
}
202
+
203
+
// Start starts the Spindle server (blocking).
204
+
func (s *Spindle) Start(ctx context.Context) error {
205
+
// starts a job queue runner in the background
206
+
s.jq.Start()
207
+
defer s.jq.Stop()
208
+
209
+
// Stop vault token renewal if it implements Stopper
210
+
if stopper, ok := s.vault.(secrets.Stopper); ok {
211
+
defer stopper.Stop()
212
+
}
213
+
188
214
go func() {
189
-
logger.Info("starting knot event consumer")
190
-
spindle.ks.Start(ctx)
215
+
s.l.Info("starting knot event consumer")
216
+
s.ks.Start(ctx)
191
217
}()
192
218
193
-
logger.Info("starting spindle server", "address", cfg.Server.ListenAddr)
194
-
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router()))
219
+
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
220
+
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
221
+
}
222
+
223
+
func Run(ctx context.Context) error {
224
+
cfg, err := config.Load(ctx)
225
+
if err != nil {
226
+
return fmt.Errorf("failed to load config: %w", err)
227
+
}
228
+
229
+
nixeryEng, err := nixery.New(ctx, cfg)
230
+
if err != nil {
231
+
return err
232
+
}
233
+
234
+
s, err := New(ctx, cfg, map[string]models.Engine{
235
+
"nixery": nixeryEng,
236
+
})
237
+
if err != nil {
238
+
return err
239
+
}
195
240
196
-
return nil
241
+
return s.Start(ctx)
197
242
}
198
243
199
244
func (s *Spindle) Router() http.Handler {
+5
spindle/stream.go
+5
spindle/stream.go
···
213
213
if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil {
214
214
return fmt.Errorf("failed to write to websocket: %w", err)
215
215
}
216
+
case <-time.After(30 * time.Second):
217
+
// send a keep-alive
218
+
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
219
+
return fmt.Errorf("failed to write control: %w", err)
220
+
}
216
221
}
217
222
}
218
223
}
+1
-1
types/repo.go
+1
-1
types/repo.go
+28
-5
types/tree.go
+28
-5
types/tree.go
···
4
4
"time"
5
5
6
6
"github.com/go-git/go-git/v5/plumbing"
7
+
"github.com/go-git/go-git/v5/plumbing/filemode"
7
8
)
8
9
9
10
// A nicer git tree representation.
10
11
type NiceTree struct {
11
12
// Relative path
12
-
Name string `json:"name"`
13
-
Mode string `json:"mode"`
14
-
Size int64 `json:"size"`
15
-
IsFile bool `json:"is_file"`
16
-
IsSubtree bool `json:"is_subtree"`
13
+
Name string `json:"name"`
14
+
Mode string `json:"mode"`
15
+
Size int64 `json:"size"`
17
16
18
17
LastCommit *LastCommitInfo `json:"last_commit,omitempty"`
18
+
}
19
+
20
+
func (t *NiceTree) FileMode() (filemode.FileMode, error) {
21
+
return filemode.New(t.Mode)
22
+
}
23
+
24
+
func (t *NiceTree) IsFile() bool {
25
+
m, err := t.FileMode()
26
+
27
+
if err != nil {
28
+
return false
29
+
}
30
+
31
+
return m.IsFile()
32
+
}
33
+
34
+
func (t *NiceTree) IsSubmodule() bool {
35
+
m, err := t.FileMode()
36
+
37
+
if err != nil {
38
+
return false
39
+
}
40
+
41
+
return m == filemode.Submodule
19
42
}
20
43
21
44
type LastCommitInfo struct {