+79
-20
api/tangled/cbor_gen.go
+79
-20
api/tangled/cbor_gen.go
···
7934
}
7935
7936
cw := cbg.NewCborWriter(w)
7937
-
fieldCount := 9
7938
7939
if t.Body == nil {
7940
fieldCount--
7941
}
7942
7943
if t.Mentions == nil {
7944
fieldCount--
7945
}
7946
···
8008
}
8009
8010
// t.Patch (string) (string)
8011
-
if len("patch") > 1000000 {
8012
-
return xerrors.Errorf("Value in field \"patch\" was too long")
8013
-
}
8014
8015
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil {
8016
-
return err
8017
-
}
8018
-
if _, err := cw.WriteString(string("patch")); err != nil {
8019
-
return err
8020
-
}
8021
8022
-
if len(t.Patch) > 1000000 {
8023
-
return xerrors.Errorf("Value in field t.Patch was too long")
8024
-
}
8025
8026
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Patch))); err != nil {
8027
-
return err
8028
-
}
8029
-
if _, err := cw.WriteString(string(t.Patch)); err != nil {
8030
-
return err
8031
}
8032
8033
// t.Title (string) (string)
···
8147
return err
8148
}
8149
8150
// t.References ([]string) (slice)
8151
if t.References != nil {
8152
···
8262
case "patch":
8263
8264
{
8265
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8266
if err != nil {
8267
return err
8268
}
8269
8270
-
t.Patch = string(sval)
8271
}
8272
// t.Title (string) (string)
8273
case "title":
···
8370
}
8371
8372
t.CreatedAt = string(sval)
8373
}
8374
// t.References ([]string) (slice)
8375
case "references":
···
7934
}
7935
7936
cw := cbg.NewCborWriter(w)
7937
+
fieldCount := 10
7938
7939
if t.Body == nil {
7940
fieldCount--
7941
}
7942
7943
if t.Mentions == nil {
7944
+
fieldCount--
7945
+
}
7946
+
7947
+
if t.Patch == nil {
7948
fieldCount--
7949
}
7950
···
8012
}
8013
8014
// t.Patch (string) (string)
8015
+
if t.Patch != nil {
8016
8017
+
if len("patch") > 1000000 {
8018
+
return xerrors.Errorf("Value in field \"patch\" was too long")
8019
+
}
8020
8021
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil {
8022
+
return err
8023
+
}
8024
+
if _, err := cw.WriteString(string("patch")); err != nil {
8025
+
return err
8026
+
}
8027
+
8028
+
if t.Patch == nil {
8029
+
if _, err := cw.Write(cbg.CborNull); err != nil {
8030
+
return err
8031
+
}
8032
+
} else {
8033
+
if len(*t.Patch) > 1000000 {
8034
+
return xerrors.Errorf("Value in field t.Patch was too long")
8035
+
}
8036
8037
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Patch))); err != nil {
8038
+
return err
8039
+
}
8040
+
if _, err := cw.WriteString(string(*t.Patch)); err != nil {
8041
+
return err
8042
+
}
8043
+
}
8044
}
8045
8046
// t.Title (string) (string)
···
8160
return err
8161
}
8162
8163
+
// t.PatchBlob (util.LexBlob) (struct)
8164
+
if len("patchBlob") > 1000000 {
8165
+
return xerrors.Errorf("Value in field \"patchBlob\" was too long")
8166
+
}
8167
+
8168
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patchBlob"))); err != nil {
8169
+
return err
8170
+
}
8171
+
if _, err := cw.WriteString(string("patchBlob")); err != nil {
8172
+
return err
8173
+
}
8174
+
8175
+
if err := t.PatchBlob.MarshalCBOR(cw); err != nil {
8176
+
return err
8177
+
}
8178
+
8179
// t.References ([]string) (slice)
8180
if t.References != nil {
8181
···
8291
case "patch":
8292
8293
{
8294
+
b, err := cr.ReadByte()
8295
if err != nil {
8296
return err
8297
}
8298
+
if b != cbg.CborNull[0] {
8299
+
if err := cr.UnreadByte(); err != nil {
8300
+
return err
8301
+
}
8302
8303
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8304
+
if err != nil {
8305
+
return err
8306
+
}
8307
+
8308
+
t.Patch = (*string)(&sval)
8309
+
}
8310
}
8311
// t.Title (string) (string)
8312
case "title":
···
8409
}
8410
8411
t.CreatedAt = string(sval)
8412
+
}
8413
+
// t.PatchBlob (util.LexBlob) (struct)
8414
+
case "patchBlob":
8415
+
8416
+
{
8417
+
8418
+
b, err := cr.ReadByte()
8419
+
if err != nil {
8420
+
return err
8421
+
}
8422
+
if b != cbg.CborNull[0] {
8423
+
if err := cr.UnreadByte(); err != nil {
8424
+
return err
8425
+
}
8426
+
t.PatchBlob = new(util.LexBlob)
8427
+
if err := t.PatchBlob.UnmarshalCBOR(cr); err != nil {
8428
+
return xerrors.Errorf("unmarshaling t.PatchBlob pointer: %w", err)
8429
+
}
8430
+
}
8431
+
8432
}
8433
// t.References ([]string) (slice)
8434
case "references":
-34
api/tangled/pipelinecancelPipeline.go
-34
api/tangled/pipelinecancelPipeline.go
···
1
-
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
-
3
-
package tangled
4
-
5
-
// schema: sh.tangled.pipeline.cancelPipeline
6
-
7
-
import (
8
-
"context"
9
-
10
-
"github.com/bluesky-social/indigo/lex/util"
11
-
)
12
-
13
-
const (
14
-
PipelineCancelPipelineNSID = "sh.tangled.pipeline.cancelPipeline"
15
-
)
16
-
17
-
// PipelineCancelPipeline_Input is the input argument to a sh.tangled.pipeline.cancelPipeline call.
18
-
type PipelineCancelPipeline_Input struct {
19
-
// pipeline: pipeline at-uri
20
-
Pipeline string `json:"pipeline" cborgen:"pipeline"`
21
-
// repo: repo at-uri, spindle can't resolve repo from pipeline at-uri yet
22
-
Repo string `json:"repo" cborgen:"repo"`
23
-
// workflow: workflow name
24
-
Workflow string `json:"workflow" cborgen:"workflow"`
25
-
}
26
-
27
-
// PipelineCancelPipeline calls the XRPC method "sh.tangled.pipeline.cancelPipeline".
28
-
func PipelineCancelPipeline(ctx context.Context, c util.LexClient, input *PipelineCancelPipeline_Input) error {
29
-
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.pipeline.cancelPipeline", nil, input, nil); err != nil {
30
-
return err
31
-
}
32
-
33
-
return nil
34
-
}
···
+12
-9
api/tangled/repopull.go
+12
-9
api/tangled/repopull.go
···
17
} //
18
// RECORDTYPE: RepoPull
19
type RepoPull struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
-
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
-
Patch string `json:"patch" cborgen:"patch"`
25
-
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
26
-
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
27
-
Target *RepoPull_Target `json:"target" cborgen:"target"`
28
-
Title string `json:"title" cborgen:"title"`
29
}
30
31
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
···
17
} //
18
// RECORDTYPE: RepoPull
19
type RepoPull struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
+
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
+
// patch: (deprecated) use patchBlob instead
25
+
Patch *string `json:"patch,omitempty" cborgen:"patch,omitempty"`
26
+
// patchBlob: patch content
27
+
PatchBlob *util.LexBlob `json:"patchBlob" cborgen:"patchBlob"`
28
+
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
29
+
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
30
+
Target *RepoPull_Target `json:"target" cborgen:"target"`
31
+
Title string `json:"title" cborgen:"title"`
32
}
33
34
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
+6
-6
appview/db/pipeline.go
+6
-6
appview/db/pipeline.go
···
6
"strings"
7
"time"
8
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
"tangled.org/core/appview/models"
11
"tangled.org/core/orm"
12
)
···
217
}
218
defer rows.Close()
219
220
-
pipelines := make(map[syntax.ATURI]models.Pipeline)
221
for rows.Next() {
222
var p models.Pipeline
223
var t models.Trigger
···
254
p.Trigger = &t
255
p.Statuses = make(map[string]models.WorkflowStatus)
256
257
-
pipelines[p.AtUri()] = p
258
}
259
260
// get all statuses
···
314
return nil, fmt.Errorf("invalid status created timestamp %q: %w", created, err)
315
}
316
317
-
pipelineAt := ps.PipelineAt()
318
319
// extract
320
-
pipeline, ok := pipelines[pipelineAt]
321
if !ok {
322
continue
323
}
···
331
332
// reassign
333
pipeline.Statuses[ps.Workflow] = statuses
334
-
pipelines[pipelineAt] = pipeline
335
}
336
337
var all []models.Pipeline
···
6
"strings"
7
"time"
8
9
"tangled.org/core/appview/models"
10
"tangled.org/core/orm"
11
)
···
216
}
217
defer rows.Close()
218
219
+
pipelines := make(map[string]models.Pipeline)
220
for rows.Next() {
221
var p models.Pipeline
222
var t models.Trigger
···
253
p.Trigger = &t
254
p.Statuses = make(map[string]models.WorkflowStatus)
255
256
+
k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey)
257
+
pipelines[k] = p
258
}
259
260
// get all statuses
···
314
return nil, fmt.Errorf("invalid status created timestamp %q: %w", created, err)
315
}
316
317
+
key := fmt.Sprintf("%s/%s", ps.PipelineKnot, ps.PipelineRkey)
318
319
// extract
320
+
pipeline, ok := pipelines[key]
321
if !ok {
322
continue
323
}
···
331
332
// reassign
333
pipeline.Statuses[ps.Workflow] = statuses
334
+
pipelines[key] = pipeline
335
}
336
337
var all []models.Pipeline
+18
-11
appview/db/profile.go
+18
-11
appview/db/profile.go
···
20
timeline := models.ProfileTimeline{
21
ByMonth: make([]models.ByMonth, TimeframeMonths),
22
}
23
-
currentMonth := time.Now().Month()
24
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
25
26
pulls, err := GetPullsByOwnerDid(e, forDid, timeframe)
···
30
31
// group pulls by month
32
for _, pull := range pulls {
33
-
pullMonth := pull.Created.Month()
34
35
-
if currentMonth-pullMonth >= TimeframeMonths {
36
// shouldn't happen; but times are weird
37
continue
38
}
39
40
-
idx := currentMonth - pullMonth
41
items := &timeline.ByMonth[idx].PullEvents.Items
42
43
*items = append(*items, &pull)
···
53
}
54
55
for _, issue := range issues {
56
-
issueMonth := issue.Created.Month()
57
58
-
if currentMonth-issueMonth >= TimeframeMonths {
59
// shouldn't happen; but times are weird
60
continue
61
}
62
63
-
idx := currentMonth - issueMonth
64
items := &timeline.ByMonth[idx].IssueEvents.Items
65
66
*items = append(*items, &issue)
···
77
if repo.Source != "" {
78
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
79
if err != nil {
80
-
return nil, err
81
}
82
}
83
84
-
repoMonth := repo.Created.Month()
85
86
-
if currentMonth-repoMonth >= TimeframeMonths {
87
// shouldn't happen; but times are weird
88
continue
89
}
90
91
-
idx := currentMonth - repoMonth
92
93
items := &timeline.ByMonth[idx].RepoEvents
94
*items = append(*items, models.RepoEvent{
···
98
}
99
100
return &timeline, nil
101
}
102
103
func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
···
20
timeline := models.ProfileTimeline{
21
ByMonth: make([]models.ByMonth, TimeframeMonths),
22
}
23
+
now := time.Now()
24
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
25
26
pulls, err := GetPullsByOwnerDid(e, forDid, timeframe)
···
30
31
// group pulls by month
32
for _, pull := range pulls {
33
+
monthsAgo := monthsBetween(pull.Created, now)
34
35
+
if monthsAgo >= TimeframeMonths {
36
// shouldn't happen; but times are weird
37
continue
38
}
39
40
+
idx := monthsAgo
41
items := &timeline.ByMonth[idx].PullEvents.Items
42
43
*items = append(*items, &pull)
···
53
}
54
55
for _, issue := range issues {
56
+
monthsAgo := monthsBetween(issue.Created, now)
57
58
+
if monthsAgo >= TimeframeMonths {
59
// shouldn't happen; but times are weird
60
continue
61
}
62
63
+
idx := monthsAgo
64
items := &timeline.ByMonth[idx].IssueEvents.Items
65
66
*items = append(*items, &issue)
···
77
if repo.Source != "" {
78
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
79
if err != nil {
80
+
// the source repo was not found, skip this bit
81
+
log.Println("profile", "err", err)
82
}
83
}
84
85
+
monthsAgo := monthsBetween(repo.Created, now)
86
87
+
if monthsAgo >= TimeframeMonths {
88
// shouldn't happen; but times are weird
89
continue
90
}
91
92
+
idx := monthsAgo
93
94
items := &timeline.ByMonth[idx].RepoEvents
95
*items = append(*items, models.RepoEvent{
···
99
}
100
101
return &timeline, nil
102
+
}
103
+
104
+
func monthsBetween(from, to time.Time) int {
105
+
years := to.Year() - from.Year()
106
+
months := int(to.Month() - from.Month())
107
+
return years*12 + months
108
}
109
110
func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
+1
-1
appview/db/punchcard.go
+1
-1
appview/db/punchcard.go
+8
-9
appview/issues/issues.go
+8
-9
appview/issues/issues.go
···
129
}
130
131
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
132
-
LoggedInUser: user,
133
-
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
134
-
Issue: issue,
135
-
CommentList: issue.CommentList(),
136
-
Backlinks: backlinks,
137
-
OrderedReactionKinds: models.OrderedReactionKinds,
138
-
Reactions: reactionMap,
139
-
UserReacted: userReactions,
140
-
LabelDefs: defs,
141
})
142
}
143
···
129
}
130
131
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
132
+
LoggedInUser: user,
133
+
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
134
+
Issue: issue,
135
+
CommentList: issue.CommentList(),
136
+
Backlinks: backlinks,
137
+
Reactions: reactionMap,
138
+
UserReacted: userReactions,
139
+
LabelDefs: defs,
140
})
141
}
142
+2
-2
appview/issues/opengraph.go
+2
-2
appview/issues/opengraph.go
···
193
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196
-
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
197
if err != nil {
198
-
log.Printf("dolly silhouette not available (this is ok): %v", err)
199
}
200
201
// Draw "opened by @author" and date at the bottom with more spacing
···
193
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196
+
err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor)
197
if err != nil {
198
+
log.Printf("dolly not available (this is ok): %v", err)
199
}
200
201
// Draw "opened by @author" and date at the bottom with more spacing
-20
appview/knots/knots.go
-20
appview/knots/knots.go
···
40
Knotstream *eventconsumer.Consumer
41
}
42
43
-
type tab = map[string]any
44
-
45
-
var (
46
-
knotsTabs []tab = []tab{
47
-
{"Name": "profile", "Icon": "user"},
48
-
{"Name": "keys", "Icon": "key"},
49
-
{"Name": "emails", "Icon": "mail"},
50
-
{"Name": "notifications", "Icon": "bell"},
51
-
{"Name": "knots", "Icon": "volleyball"},
52
-
{"Name": "spindles", "Icon": "spool"},
53
-
}
54
-
)
55
-
56
func (k *Knots) Router() http.Handler {
57
r := chi.NewRouter()
58
···
84
k.Pages.Knots(w, pages.KnotsParams{
85
LoggedInUser: user,
86
Registrations: registrations,
87
-
Tabs: knotsTabs,
88
Tab: "knots",
89
})
90
}
···
148
Members: members,
149
Repos: repoMap,
150
IsOwner: true,
151
-
Tabs: knotsTabs,
152
Tab: "knots",
153
})
154
}
···
663
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
664
if err != nil {
665
l.Error("failed to resolve member identity to handle", "err", err)
666
-
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
667
-
return
668
-
}
669
-
if memberId.Handle.IsInvalidHandle() {
670
-
l.Error("failed to resolve member identity to handle")
671
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
672
return
673
}
···
40
Knotstream *eventconsumer.Consumer
41
}
42
43
func (k *Knots) Router() http.Handler {
44
r := chi.NewRouter()
45
···
71
k.Pages.Knots(w, pages.KnotsParams{
72
LoggedInUser: user,
73
Registrations: registrations,
74
Tab: "knots",
75
})
76
}
···
134
Members: members,
135
Repos: repoMap,
136
IsOwner: true,
137
Tab: "knots",
138
})
139
}
···
648
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
649
if err != nil {
650
l.Error("failed to resolve member identity to handle", "err", err)
651
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
652
return
653
}
+4
appview/middleware/middleware.go
+4
appview/middleware/middleware.go
···
223
)
224
if err != nil {
225
log.Println("failed to resolve repo", "err", err)
226
mw.pages.ErrorKnot404(w)
227
return
228
}
···
240
f, err := mw.repoResolver.Resolve(r)
241
if err != nil {
242
log.Println("failed to fully resolve repo", err)
243
mw.pages.ErrorKnot404(w)
244
return
245
}
···
288
f, err := mw.repoResolver.Resolve(r)
289
if err != nil {
290
log.Println("failed to fully resolve repo", err)
291
mw.pages.ErrorKnot404(w)
292
return
293
}
···
324
f, err := mw.repoResolver.Resolve(r)
325
if err != nil {
326
log.Println("failed to fully resolve repo", err)
327
mw.pages.ErrorKnot404(w)
328
return
329
}
···
223
)
224
if err != nil {
225
log.Println("failed to resolve repo", "err", err)
226
+
w.WriteHeader(http.StatusNotFound)
227
mw.pages.ErrorKnot404(w)
228
return
229
}
···
241
f, err := mw.repoResolver.Resolve(r)
242
if err != nil {
243
log.Println("failed to fully resolve repo", err)
244
+
w.WriteHeader(http.StatusNotFound)
245
mw.pages.ErrorKnot404(w)
246
return
247
}
···
290
f, err := mw.repoResolver.Resolve(r)
291
if err != nil {
292
log.Println("failed to fully resolve repo", err)
293
+
w.WriteHeader(http.StatusNotFound)
294
mw.pages.ErrorKnot404(w)
295
return
296
}
···
327
f, err := mw.repoResolver.Resolve(r)
328
if err != nil {
329
log.Println("failed to fully resolve repo", err)
330
+
w.WriteHeader(http.StatusNotFound)
331
mw.pages.ErrorKnot404(w)
332
return
333
}
-10
appview/models/pipeline.go
-10
appview/models/pipeline.go
···
1
package models
2
3
import (
4
-
"fmt"
5
"slices"
6
"time"
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
"github.com/go-git/go-git/v5/plumbing"
10
-
"tangled.org/core/api/tangled"
11
spindle "tangled.org/core/spindle/models"
12
"tangled.org/core/workflow"
13
)
···
25
// populate when querying for reverse mappings
26
Trigger *Trigger
27
Statuses map[string]WorkflowStatus
28
-
}
29
-
30
-
func (p *Pipeline) AtUri() syntax.ATURI {
31
-
return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", p.Knot, tangled.PipelineNSID, p.Rkey))
32
}
33
34
type WorkflowStatus struct {
···
134
Error *string
135
ExitCode int
136
}
137
-
138
-
func (ps *PipelineStatus) PipelineAt() syntax.ATURI {
139
-
return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", ps.PipelineKnot, tangled.PipelineNSID, ps.PipelineRkey))
140
-
}
···
1
package models
2
3
import (
4
"slices"
5
"time"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
"github.com/go-git/go-git/v5/plumbing"
9
spindle "tangled.org/core/spindle/models"
10
"tangled.org/core/workflow"
11
)
···
23
// populate when querying for reverse mappings
24
Trigger *Trigger
25
Statuses map[string]WorkflowStatus
26
}
27
28
type WorkflowStatus struct {
···
128
Error *string
129
ExitCode int
130
}
+1
-1
appview/models/pull.go
+1
-1
appview/models/pull.go
···
83
Repo *Repo
84
}
85
86
+
// NOTE: This method does not include patch blob in returned atproto record
87
func (p Pull) AsRecord() tangled.RepoPull {
88
var source *tangled.RepoPull_Source
89
if p.PullSource != nil {
···
114
Repo: p.RepoAt.String(),
115
Branch: p.TargetBranch,
116
},
117
Source: source,
118
}
119
return record
+9
-9
appview/ogcard/card.go
+9
-9
appview/ogcard/card.go
···
334
return nil
335
}
336
337
-
func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error {
338
tpl, err := template.New("dolly").
339
-
ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html")
340
if err != nil {
341
-
return fmt.Errorf("failed to read dolly silhouette template: %w", err)
342
}
343
344
var svgData bytes.Buffer
345
-
if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil {
346
-
return fmt.Errorf("failed to execute dolly silhouette template: %w", err)
347
}
348
349
icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor)
···
453
454
// Handle SVG separately
455
if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") {
456
-
return c.convertSVGToPNG(bodyBytes)
457
}
458
459
// Support content types are in-sync with the allowed custom avatar file types
···
493
}
494
495
// convertSVGToPNG converts SVG data to a PNG image
496
-
func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) {
497
// Parse the SVG
498
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
499
if err != nil {
···
547
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
548
549
// Draw the image with circular clipping
550
-
for cy := 0; cy < size; cy++ {
551
-
for cx := 0; cx < size; cx++ {
552
// Calculate distance from center
553
dx := float64(cx - center)
554
dy := float64(cy - center)
···
334
return nil
335
}
336
337
+
func (c *Card) DrawDolly(x, y, size int, iconColor color.Color) error {
338
tpl, err := template.New("dolly").
339
+
ParseFS(pages.Files, "templates/fragments/dolly/logo.html")
340
if err != nil {
341
+
return fmt.Errorf("failed to read dolly template: %w", err)
342
}
343
344
var svgData bytes.Buffer
345
+
if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", nil); err != nil {
346
+
return fmt.Errorf("failed to execute dolly template: %w", err)
347
}
348
349
icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor)
···
453
454
// Handle SVG separately
455
if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") {
456
+
return convertSVGToPNG(bodyBytes)
457
}
458
459
// Support content types are in-sync with the allowed custom avatar file types
···
493
}
494
495
// convertSVGToPNG converts SVG data to a PNG image
496
+
func convertSVGToPNG(svgData []byte) (image.Image, bool) {
497
// Parse the SVG
498
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
499
if err != nil {
···
547
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
548
549
// Draw the image with circular clipping
550
+
for cy := range size {
551
+
for cx := range size {
552
// Calculate distance from center
553
dx := float64(cx - center)
554
dy := float64(cy - center)
+22
appview/pages/funcmap.go
+22
appview/pages/funcmap.go
···
32
"tangled.org/core/crypto"
33
)
34
35
+
type tab map[string]string
36
+
37
func (p *Pages) funcMap() template.FuncMap {
38
return template.FuncMap{
39
"split": func(s string) []string {
···
386
return "error"
387
}
388
return fp
389
+
},
390
+
// constant values used to define a template
391
+
"const": func() map[string]any {
392
+
return map[string]any{
393
+
"OrderedReactionKinds": models.OrderedReactionKinds,
394
+
// would be great to have ordered maps right about now
395
+
"UserSettingsTabs": []tab{
396
+
{"Name": "profile", "Icon": "user"},
397
+
{"Name": "keys", "Icon": "key"},
398
+
{"Name": "emails", "Icon": "mail"},
399
+
{"Name": "notifications", "Icon": "bell"},
400
+
{"Name": "knots", "Icon": "volleyball"},
401
+
{"Name": "spindles", "Icon": "spool"},
402
+
},
403
+
"RepoSettingsTabs": []tab{
404
+
{"Name": "general", "Icon": "sliders-horizontal"},
405
+
{"Name": "access", "Icon": "users"},
406
+
{"Name": "pipelines", "Icon": "layers-2"},
407
+
},
408
+
}
409
},
410
}
411
}
+30
-35
appview/pages/pages.go
+30
-35
appview/pages/pages.go
···
210
return tpl.ExecuteTemplate(w, "layouts/base", params)
211
}
212
213
func (p *Pages) Favicon(w io.Writer) error {
214
-
return p.executePlain("fragments/dolly/silhouette", w, nil)
215
}
216
217
type LoginParams struct {
···
325
326
type UserProfileSettingsParams struct {
327
LoggedInUser *oauth.User
328
-
Tabs []map[string]any
329
Tab string
330
}
331
···
364
type UserKeysSettingsParams struct {
365
LoggedInUser *oauth.User
366
PubKeys []models.PublicKey
367
-
Tabs []map[string]any
368
Tab string
369
}
370
···
375
type UserEmailsSettingsParams struct {
376
LoggedInUser *oauth.User
377
Emails []models.Email
378
-
Tabs []map[string]any
379
Tab string
380
}
381
···
386
type UserNotificationSettingsParams struct {
387
LoggedInUser *oauth.User
388
Preferences *models.NotificationPreferences
389
-
Tabs []map[string]any
390
Tab string
391
}
392
···
406
type KnotsParams struct {
407
LoggedInUser *oauth.User
408
Registrations []models.Registration
409
-
Tabs []map[string]any
410
Tab string
411
}
412
···
420
Members []string
421
Repos map[string][]models.Repo
422
IsOwner bool
423
-
Tabs []map[string]any
424
Tab string
425
}
426
···
439
type SpindlesParams struct {
440
LoggedInUser *oauth.User
441
Spindles []models.Spindle
442
-
Tabs []map[string]any
443
Tab string
444
}
445
···
449
450
type SpindleListingParams struct {
451
models.Spindle
452
-
Tabs []map[string]any
453
Tab string
454
}
455
···
462
Spindle models.Spindle
463
Members []string
464
Repos map[string][]models.Repo
465
-
Tabs []map[string]any
466
Tab string
467
}
468
···
870
SubscribedLabels map[string]struct{}
871
ShouldSubscribeAll bool
872
Active string
873
-
Tabs []map[string]any
874
Tab string
875
Branches []types.Branch
876
}
···
884
LoggedInUser *oauth.User
885
RepoInfo repoinfo.RepoInfo
886
Active string
887
-
Tabs []map[string]any
888
Tab string
889
Collaborators []Collaborator
890
}
···
898
LoggedInUser *oauth.User
899
RepoInfo repoinfo.RepoInfo
900
Active string
901
-
Tabs []map[string]any
902
Tab string
903
Spindles []string
904
CurrentSpindle string
···
936
Backlinks []models.RichReferenceLink
937
LabelDefs map[string]*models.LabelDefinition
938
939
-
OrderedReactionKinds []models.ReactionKind
940
-
Reactions map[models.ReactionKind]models.ReactionDisplayData
941
-
UserReacted map[models.ReactionKind]bool
942
}
943
944
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
···
1093
ResubmitCheck ResubmitResult
1094
Pipelines map[string]models.Pipeline
1095
1096
-
OrderedReactionKinds []models.ReactionKind
1097
-
Reactions map[models.ReactionKind]models.ReactionDisplayData
1098
-
UserReacted map[models.ReactionKind]bool
1099
1100
LabelDefs map[string]*models.LabelDefinition
1101
}
···
1106
}
1107
1108
type RepoPullPatchParams struct {
1109
-
LoggedInUser *oauth.User
1110
-
RepoInfo repoinfo.RepoInfo
1111
-
Pull *models.Pull
1112
-
Stack models.Stack
1113
-
Diff *types.NiceDiff
1114
-
Round int
1115
-
Submission *models.PullSubmission
1116
-
OrderedReactionKinds []models.ReactionKind
1117
-
DiffOpts types.DiffOpts
1118
}
1119
1120
// this name is a mouthful
···
1123
}
1124
1125
type RepoPullInterdiffParams struct {
1126
-
LoggedInUser *oauth.User
1127
-
RepoInfo repoinfo.RepoInfo
1128
-
Pull *models.Pull
1129
-
Round int
1130
-
Interdiff *patchutil.InterdiffResult
1131
-
OrderedReactionKinds []models.ReactionKind
1132
-
DiffOpts types.DiffOpts
1133
}
1134
1135
// this name is a mouthful
···
210
return tpl.ExecuteTemplate(w, "layouts/base", params)
211
}
212
213
+
type DollyParams struct {
214
+
Classes string
215
+
FillColor string
216
+
}
217
+
218
+
func (p *Pages) Dolly(w io.Writer, params DollyParams) error {
219
+
return p.executePlain("fragments/dolly/logo", w, params)
220
+
}
221
+
222
func (p *Pages) Favicon(w io.Writer) error {
223
+
return p.Dolly(w, DollyParams{
224
+
Classes: "text-black dark:text-white",
225
+
})
226
}
227
228
type LoginParams struct {
···
336
337
type UserProfileSettingsParams struct {
338
LoggedInUser *oauth.User
339
Tab string
340
}
341
···
374
type UserKeysSettingsParams struct {
375
LoggedInUser *oauth.User
376
PubKeys []models.PublicKey
377
Tab string
378
}
379
···
384
type UserEmailsSettingsParams struct {
385
LoggedInUser *oauth.User
386
Emails []models.Email
387
Tab string
388
}
389
···
394
type UserNotificationSettingsParams struct {
395
LoggedInUser *oauth.User
396
Preferences *models.NotificationPreferences
397
Tab string
398
}
399
···
413
type KnotsParams struct {
414
LoggedInUser *oauth.User
415
Registrations []models.Registration
416
Tab string
417
}
418
···
426
Members []string
427
Repos map[string][]models.Repo
428
IsOwner bool
429
Tab string
430
}
431
···
444
type SpindlesParams struct {
445
LoggedInUser *oauth.User
446
Spindles []models.Spindle
447
Tab string
448
}
449
···
453
454
type SpindleListingParams struct {
455
models.Spindle
456
Tab string
457
}
458
···
465
Spindle models.Spindle
466
Members []string
467
Repos map[string][]models.Repo
468
Tab string
469
}
470
···
872
SubscribedLabels map[string]struct{}
873
ShouldSubscribeAll bool
874
Active string
875
Tab string
876
Branches []types.Branch
877
}
···
885
LoggedInUser *oauth.User
886
RepoInfo repoinfo.RepoInfo
887
Active string
888
Tab string
889
Collaborators []Collaborator
890
}
···
898
LoggedInUser *oauth.User
899
RepoInfo repoinfo.RepoInfo
900
Active string
901
Tab string
902
Spindles []string
903
CurrentSpindle string
···
935
Backlinks []models.RichReferenceLink
936
LabelDefs map[string]*models.LabelDefinition
937
938
+
Reactions map[models.ReactionKind]models.ReactionDisplayData
939
+
UserReacted map[models.ReactionKind]bool
940
}
941
942
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
···
1091
ResubmitCheck ResubmitResult
1092
Pipelines map[string]models.Pipeline
1093
1094
+
Reactions map[models.ReactionKind]models.ReactionDisplayData
1095
+
UserReacted map[models.ReactionKind]bool
1096
1097
LabelDefs map[string]*models.LabelDefinition
1098
}
···
1103
}
1104
1105
type RepoPullPatchParams struct {
1106
+
LoggedInUser *oauth.User
1107
+
RepoInfo repoinfo.RepoInfo
1108
+
Pull *models.Pull
1109
+
Stack models.Stack
1110
+
Diff *types.NiceDiff
1111
+
Round int
1112
+
Submission *models.PullSubmission
1113
+
DiffOpts types.DiffOpts
1114
}
1115
1116
// this name is a mouthful
···
1119
}
1120
1121
type RepoPullInterdiffParams struct {
1122
+
LoggedInUser *oauth.User
1123
+
RepoInfo repoinfo.RepoInfo
1124
+
Pull *models.Pull
1125
+
Round int
1126
+
Interdiff *patchutil.InterdiffResult
1127
+
DiffOpts types.DiffOpts
1128
}
1129
1130
// this name is a mouthful
+9
-29
appview/pages/templates/brand/brand.html
+9
-29
appview/pages/templates/brand/brand.html
···
4
<div class="grid grid-cols-10">
5
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
7
-
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
Assets and guidelines for using Tangled's logo and brand elements.
9
</p>
10
</header>
···
14
15
<!-- Introduction Section -->
16
<section>
17
-
<p class="text-gray-600 dark:text-gray-400 mb-2">
18
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
19
follow the below guidelines when using Dolly and the logotype.
20
</p>
21
-
<p class="text-gray-600 dark:text-gray-400 mb-2">
22
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
23
</p>
24
</section>
···
34
</div>
35
<div class="order-1 lg:order-2">
36
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
37
-
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p>
38
<p class="text-gray-700 dark:text-gray-300">
39
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
40
backgrounds and designs.
···
53
</div>
54
<div class="order-1 lg:order-2">
55
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
56
-
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p>
57
<p class="text-gray-700 dark:text-gray-300">
58
This version features white text and elements, ideal for dark backgrounds
59
and inverted designs.
···
81
</div>
82
<div class="order-1 lg:order-2">
83
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
84
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
85
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
86
</p>
87
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
123
</div>
124
<div class="order-1 lg:order-2">
125
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
126
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
127
White logo mark on colored backgrounds.
128
</p>
129
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
165
</div>
166
<div class="order-1 lg:order-2">
167
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
168
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
169
Dark logo mark on lighter, pastel backgrounds.
170
</p>
171
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
186
</div>
187
<div class="order-1 lg:order-2">
188
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
189
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
190
Custom coloring of the logotype is permitted.
191
</p>
192
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
194
</p>
195
<p class="text-gray-700 dark:text-gray-300 text-sm">
196
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
197
-
</p>
198
-
</div>
199
-
</section>
200
-
201
-
<!-- Silhouette Section -->
202
-
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
203
-
<div class="order-2 lg:order-1">
204
-
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
205
-
<img src="https://assets.tangled.network/tangled_dolly_silhouette.svg"
206
-
alt="Dolly silhouette"
207
-
class="w-full max-w-32 mx-auto" />
208
-
</div>
209
-
</div>
210
-
<div class="order-1 lg:order-2">
211
-
<h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2>
212
-
<p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p>
213
-
<p class="text-gray-700 dark:text-gray-300">
214
-
The silhouette can be used where a subtle brand presence is needed,
215
-
or as a background element. Works on any background color with proper contrast.
216
-
For example, we use this as the site's favicon.
217
</p>
218
</div>
219
</section>
···
4
<div class="grid grid-cols-10">
5
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
7
+
<p class="text-gray-500 dark:text-gray-300 mb-1">
8
Assets and guidelines for using Tangled's logo and brand elements.
9
</p>
10
</header>
···
14
15
<!-- Introduction Section -->
16
<section>
17
+
<p class="text-gray-500 dark:text-gray-300 mb-2">
18
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
19
follow the below guidelines when using Dolly and the logotype.
20
</p>
21
+
<p class="text-gray-500 dark:text-gray-300 mb-2">
22
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
23
</p>
24
</section>
···
34
</div>
35
<div class="order-1 lg:order-2">
36
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
37
+
<p class="text-gray-500 dark:text-gray-300 mb-4">For use on light-colored backgrounds.</p>
38
<p class="text-gray-700 dark:text-gray-300">
39
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
40
backgrounds and designs.
···
53
</div>
54
<div class="order-1 lg:order-2">
55
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
56
+
<p class="text-gray-500 dark:text-gray-300 mb-4">For use on dark-colored backgrounds.</p>
57
<p class="text-gray-700 dark:text-gray-300">
58
This version features white text and elements, ideal for dark backgrounds
59
and inverted designs.
···
81
</div>
82
<div class="order-1 lg:order-2">
83
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
84
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
85
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
86
</p>
87
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
123
</div>
124
<div class="order-1 lg:order-2">
125
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
126
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
127
White logo mark on colored backgrounds.
128
</p>
129
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
165
</div>
166
<div class="order-1 lg:order-2">
167
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
168
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
169
Dark logo mark on lighter, pastel backgrounds.
170
</p>
171
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
186
</div>
187
<div class="order-1 lg:order-2">
188
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
189
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
190
Custom coloring of the logotype is permitted.
191
</p>
192
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
194
</p>
195
<p class="text-gray-700 dark:text-gray-300 text-sm">
196
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
197
</p>
198
</div>
199
</section>
+14
-2
appview/pages/templates/fragments/dolly/logo.html
+14
-2
appview/pages/templates/fragments/dolly/logo.html
···
2
<svg
3
version="1.1"
4
id="svg1"
5
-
class="{{ . }}"
6
width="25"
7
height="25"
8
viewBox="0 0 25 25"
···
17
xmlns:svg="http://www.w3.org/2000/svg"
18
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
19
xmlns:cc="http://creativecommons.org/ns#">
20
<sodipodi:namedview
21
id="namedview1"
22
pagecolor="#ffffff"
···
51
id="g1"
52
transform="translate(-0.42924038,-0.87777209)">
53
<path
54
-
fill="currentColor"
55
style="stroke-width:0.111183;"
56
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z"
57
id="path4"
···
2
<svg
3
version="1.1"
4
id="svg1"
5
+
class="{{ .Classes }}"
6
width="25"
7
height="25"
8
viewBox="0 0 25 25"
···
17
xmlns:svg="http://www.w3.org/2000/svg"
18
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
19
xmlns:cc="http://creativecommons.org/ns#">
20
+
<style>
21
+
.dolly {
22
+
color: #000000;
23
+
}
24
+
25
+
@media (prefers-color-scheme: dark) {
26
+
.dolly {
27
+
color: #ffffff;
28
+
}
29
+
}
30
+
</style>
31
<sodipodi:namedview
32
id="namedview1"
33
pagecolor="#ffffff"
···
62
id="g1"
63
transform="translate(-0.42924038,-0.87777209)">
64
<path
65
+
class="dolly"
66
+
fill="{{ or .FillColor "currentColor" }}"
67
style="stroke-width:0.111183;"
68
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z"
69
id="path4"
-95
appview/pages/templates/fragments/dolly/silhouette.html
-95
appview/pages/templates/fragments/dolly/silhouette.html
···
1
-
{{ define "fragments/dolly/silhouette" }}
2
-
<svg
3
-
version="1.1"
4
-
id="svg1"
5
-
width="25"
6
-
height="25"
7
-
viewBox="0 0 25 25"
8
-
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
9
-
inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg"
10
-
inkscape:export-xdpi="96"
11
-
inkscape:export-ydpi="96"
12
-
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
13
-
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
14
-
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
15
-
xmlns="http://www.w3.org/2000/svg"
16
-
xmlns:svg="http://www.w3.org/2000/svg"
17
-
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
18
-
xmlns:cc="http://creativecommons.org/ns#">
19
-
<style>
20
-
.dolly {
21
-
color: #000000;
22
-
}
23
-
24
-
@media (prefers-color-scheme: dark) {
25
-
.dolly {
26
-
color: #ffffff;
27
-
}
28
-
}
29
-
</style>
30
-
<sodipodi:namedview
31
-
id="namedview1"
32
-
pagecolor="#ffffff"
33
-
bordercolor="#000000"
34
-
borderopacity="0.25"
35
-
inkscape:showpageshadow="2"
36
-
inkscape:pageopacity="0.0"
37
-
inkscape:pagecheckerboard="true"
38
-
inkscape:deskcolor="#d5d5d5"
39
-
inkscape:zoom="64"
40
-
inkscape:cx="4.96875"
41
-
inkscape:cy="13.429688"
42
-
inkscape:window-width="3840"
43
-
inkscape:window-height="2160"
44
-
inkscape:window-x="0"
45
-
inkscape:window-y="0"
46
-
inkscape:window-maximized="0"
47
-
inkscape:current-layer="g1"
48
-
borderlayer="true">
49
-
<inkscape:page
50
-
x="0"
51
-
y="0"
52
-
width="25"
53
-
height="25"
54
-
id="page2"
55
-
margin="0"
56
-
bleed="0" />
57
-
</sodipodi:namedview>
58
-
<g
59
-
inkscape:groupmode="layer"
60
-
inkscape:label="Image"
61
-
id="g1"
62
-
transform="translate(-0.42924038,-0.87777209)">
63
-
<path
64
-
class="dolly"
65
-
fill="currentColor"
66
-
style="stroke-width:0.111183"
67
-
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z"
68
-
id="path7"
69
-
sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" />
70
-
</g>
71
-
<metadata
72
-
id="metadata1">
73
-
<rdf:RDF>
74
-
<cc:Work
75
-
rdf:about="">
76
-
<cc:license
77
-
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
78
-
</cc:Work>
79
-
<cc:License
80
-
rdf:about="http://creativecommons.org/licenses/by/4.0/">
81
-
<cc:permits
82
-
rdf:resource="http://creativecommons.org/ns#Reproduction" />
83
-
<cc:permits
84
-
rdf:resource="http://creativecommons.org/ns#Distribution" />
85
-
<cc:requires
86
-
rdf:resource="http://creativecommons.org/ns#Notice" />
87
-
<cc:requires
88
-
rdf:resource="http://creativecommons.org/ns#Attribution" />
89
-
<cc:permits
90
-
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
91
-
</cc:License>
92
-
</rdf:RDF>
93
-
</metadata>
94
-
</svg>
95
-
{{ end }}
···
+1
-1
appview/pages/templates/fragments/logotype.html
+1
-1
appview/pages/templates/fragments/logotype.html
···
1
{{ define "fragments/logotype" }}
2
<span class="flex items-center gap-2">
3
-
{{ template "fragments/dolly/logo" "size-16 text-black dark:text-white" }}
4
<span class="font-bold text-4xl not-italic">tangled</span>
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
alpha
···
1
{{ define "fragments/logotype" }}
2
<span class="flex items-center gap-2">
3
+
{{ template "fragments/dolly/logo" (dict "Classes" "size-16 text-black dark:text-white") }}
4
<span class="font-bold text-4xl not-italic">tangled</span>
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
alpha
+1
-1
appview/pages/templates/fragments/logotypeSmall.html
+1
-1
appview/pages/templates/fragments/logotypeSmall.html
···
1
{{ define "fragments/logotypeSmall" }}
2
<span class="flex items-center gap-2">
3
-
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
4
<span class="font-bold text-xl not-italic">tangled</span>
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
alpha
···
1
{{ define "fragments/logotypeSmall" }}
2
<span class="flex items-center gap-2">
3
+
{{ template "fragments/dolly/logo" (dict "Classes" "size-8 text-black dark:text-white")}}
4
<span class="font-bold text-xl not-italic">tangled</span>
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
alpha
+4
appview/pages/templates/layouts/base.html
+4
appview/pages/templates/layouts/base.html
···
11
<script defer src="/static/htmx-ext-ws.min.js"></script>
12
<script defer src="/static/actor-typeahead.js" type="module"></script>
13
14
+
<link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/>
15
+
<link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/>
16
+
<link rel="apple-touch-icon" href="/static/logos/dolly.png"/>
17
+
18
<!-- preconnect to image cdn -->
19
<link rel="preconnect" href="https://avatar.tangled.sh" />
20
<link rel="preconnect" href="https://camo.tangled.sh" />
+1
-5
appview/pages/templates/layouts/fragments/topbar.html
+1
-5
appview/pages/templates/layouts/fragments/topbar.html
···
3
<div class="flex justify-between p-0 items-center">
4
<div id="left-items">
5
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
6
-
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
7
-
<span class="font-bold text-xl not-italic hidden md:inline">tangled</span>
8
-
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline">
9
-
alpha
10
-
</span>
11
</a>
12
</div>
13
+1
-1
appview/pages/templates/repo/fragments/diff.html
+1
-1
appview/pages/templates/repo/fragments/diff.html
···
17
{{ else }}
18
{{ range $idx, $hunk := $diff }}
19
{{ with $hunk }}
20
-
<details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
21
<summary class="list-none cursor-pointer sticky top-0">
22
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
23
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
···
17
{{ else }}
18
{{ range $idx, $hunk := $diff }}
19
{{ with $hunk }}
20
+
<details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
21
<summary class="list-none cursor-pointer sticky top-0">
22
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
23
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+50
appview/pages/templates/repo/fragments/reactions.html
+50
appview/pages/templates/repo/fragments/reactions.html
···
···
1
+
{{ define "repo/fragments/reactions" }}
2
+
<div class="flex flex-wrap items-center gap-2">
3
+
{{- $reactions := .Reactions -}}
4
+
{{- $userReacted := .UserReacted -}}
5
+
{{- $threadAt := .ThreadAt -}}
6
+
7
+
{{ template "reactionsPopup" }}
8
+
{{ range $kind := const.OrderedReactionKinds }}
9
+
{{ $reactionData := index $reactions $kind }}
10
+
{{ template "repo/fragments/reaction"
11
+
(dict
12
+
"Kind" $kind
13
+
"Count" $reactionData.Count
14
+
"IsReacted" (index $userReacted $kind)
15
+
"ThreadAt" $threadAt
16
+
"Users" $reactionData.Users) }}
17
+
{{ end }}
18
+
</div>
19
+
{{ end }}
20
+
21
+
{{ define "reactionsPopup" }}
22
+
<details
23
+
id="reactionsPopUp"
24
+
class="relative inline-block"
25
+
>
26
+
<summary
27
+
class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700
28
+
hover:bg-gray-50
29
+
hover:border-gray-300
30
+
dark:hover:bg-gray-700
31
+
dark:hover:border-gray-600
32
+
cursor-pointer list-none"
33
+
>
34
+
{{ i "smile" "size-4" }}
35
+
</summary>
36
+
<div
37
+
class="absolute flex left-0 z-10 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg"
38
+
>
39
+
{{ range $kind := const.OrderedReactionKinds }}
40
+
<button
41
+
id="reactBtn-{{ $kind }}"
42
+
class="size-12 hover:bg-gray-100 dark:hover:bg-gray-700"
43
+
hx-on:click="this.parentElement.parentElement.removeAttribute('open')"
44
+
>
45
+
{{ $kind }}
46
+
</button>
47
+
{{ end }}
48
+
</div>
49
+
</details>
50
+
{{ end }}
-30
appview/pages/templates/repo/fragments/reactionsPopUp.html
-30
appview/pages/templates/repo/fragments/reactionsPopUp.html
···
1
-
{{ define "repo/fragments/reactionsPopUp" }}
2
-
<details
3
-
id="reactionsPopUp"
4
-
class="relative inline-block"
5
-
>
6
-
<summary
7
-
class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700
8
-
hover:bg-gray-50
9
-
hover:border-gray-300
10
-
dark:hover:bg-gray-700
11
-
dark:hover:border-gray-600
12
-
cursor-pointer list-none"
13
-
>
14
-
{{ i "smile" "size-4" }}
15
-
</summary>
16
-
<div
17
-
class="absolute flex left-0 z-10 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg"
18
-
>
19
-
{{ range $kind := . }}
20
-
<button
21
-
id="reactBtn-{{ $kind }}"
22
-
class="size-12 hover:bg-gray-100 dark:hover:bg-gray-700"
23
-
hx-on:click="this.parentElement.parentElement.removeAttribute('open')"
24
-
>
25
-
{{ $kind }}
26
-
</button>
27
-
{{ end }}
28
-
</div>
29
-
</details>
30
-
{{ end }}
···
+35
-35
appview/pages/templates/repo/fragments/splitDiff.html
+35
-35
appview/pages/templates/repo/fragments/splitDiff.html
···
3
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
4
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
5
{{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
6
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
7
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
8
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
9
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
10
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
11
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
12
<div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700">
13
-
<pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
14
{{- range .LeftLines -}}
15
{{- if .IsEmpty -}}
16
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
17
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
18
-
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
19
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
20
-
</div>
21
{{- else if eq .Op.String "-" -}}
22
-
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
24
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
25
-
<div class="px-2">{{ .Content }}</div>
26
-
</div>
27
{{- else if eq .Op.String " " -}}
28
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
30
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
31
-
<div class="px-2">{{ .Content }}</div>
32
-
</div>
33
{{- end -}}
34
{{- end -}}
35
-
{{- end -}}</div></div></pre>
36
37
-
<pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
38
{{- range .RightLines -}}
39
{{- if .IsEmpty -}}
40
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
41
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
42
-
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
43
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
44
-
</div>
45
{{- else if eq .Op.String "+" -}}
46
-
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
48
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
49
-
<div class="px-2" >{{ .Content }}</div>
50
-
</div>
51
{{- else if eq .Op.String " " -}}
52
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
54
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
55
-
<div class="px-2">{{ .Content }}</div>
56
-
</div>
57
{{- end -}}
58
{{- end -}}
59
-
{{- end -}}</div></div></pre>
60
</div>
61
{{ end }}
···
3
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
4
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
5
{{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
6
+
{{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
7
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
8
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
9
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
10
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
11
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
12
<div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700">
13
+
<div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
14
{{- range .LeftLines -}}
15
{{- if .IsEmpty -}}
16
+
<span class="{{ $emptyStyle }} {{ $containerStyle }}">
17
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span>
18
+
<span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span>
19
+
<span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span>
20
+
</span>
21
{{- else if eq .Op.String "-" -}}
22
+
<span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
+
<span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span>
24
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
25
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
26
+
</span>
27
{{- else if eq .Op.String " " -}}
28
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
+
<span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span>
30
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
31
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
32
+
</span>
33
{{- end -}}
34
{{- end -}}
35
+
{{- end -}}</div></div></div>
36
37
+
<div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
38
{{- range .RightLines -}}
39
{{- if .IsEmpty -}}
40
+
<span class="{{ $emptyStyle }} {{ $containerStyle }}">
41
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span>
42
+
<span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span>
43
+
<span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span>
44
+
</span>
45
{{- else if eq .Op.String "+" -}}
46
+
<span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></span>
48
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
49
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
50
+
</span>
51
{{- else if eq .Op.String " " -}}
52
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a> </span>
54
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
55
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
56
+
</span>
57
{{- end -}}
58
{{- end -}}
59
+
{{- end -}}</div></div></div>
60
</div>
61
{{ end }}
+21
-22
appview/pages/templates/repo/fragments/unifiedDiff.html
+21
-22
appview/pages/templates/repo/fragments/unifiedDiff.html
···
1
{{ define "repo/fragments/unifiedDiff" }}
2
{{ $name := .Id }}
3
-
<pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
4
{{- $oldStart := .OldPosition -}}
5
{{- $newStart := .NewPosition -}}
6
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}}
7
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
8
{{- $lineNrSepStyle1 := "" -}}
9
{{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
10
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
11
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
12
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
13
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
14
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
15
{{- range .Lines -}}
16
{{- if eq .Op.String "+" -}}
17
-
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
19
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
20
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
21
-
<div class="px-2">{{ .Line }}</div>
22
-
</div>
23
{{- $newStart = add64 $newStart 1 -}}
24
{{- end -}}
25
{{- if eq .Op.String "-" -}}
26
-
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
28
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
29
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
30
-
<div class="px-2">{{ .Line }}</div>
31
-
</div>
32
{{- $oldStart = add64 $oldStart 1 -}}
33
{{- end -}}
34
{{- if eq .Op.String " " -}}
35
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div>
37
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div>
38
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
39
-
<div class="px-2">{{ .Line }}</div>
40
-
</div>
41
{{- $newStart = add64 $newStart 1 -}}
42
{{- $oldStart = add64 $oldStart 1 -}}
43
{{- end -}}
44
{{- end -}}
45
-
{{- end -}}</div></div></pre>
46
{{ end }}
47
-
···
1
{{ define "repo/fragments/unifiedDiff" }}
2
{{ $name := .Id }}
3
+
<div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
4
{{- $oldStart := .OldPosition -}}
5
{{- $newStart := .NewPosition -}}
6
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}}
7
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
8
{{- $lineNrSepStyle1 := "" -}}
9
{{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
10
+
{{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
11
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
12
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
13
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
14
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
15
{{- range .Lines -}}
16
{{- if eq .Op.String "+" -}}
17
+
<span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></span>
19
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></span>
20
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
21
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
22
+
</span>
23
{{- $newStart = add64 $newStart 1 -}}
24
{{- end -}}
25
{{- if eq .Op.String "-" -}}
26
+
<span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></span>
28
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></span>
29
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
30
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
31
+
</span>
32
{{- $oldStart = add64 $oldStart 1 -}}
33
{{- end -}}
34
{{- if eq .Op.String " " -}}
35
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></span>
37
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></span>
38
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
39
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
40
+
</span>
41
{{- $newStart = add64 $newStart 1 -}}
42
{{- $oldStart = add64 $oldStart 1 -}}
43
{{- end -}}
44
{{- end -}}
45
+
{{- end -}}</div></div></div>
46
{{ end }}
+5
-21
appview/pages/templates/repo/issues/issue.html
+5
-21
appview/pages/templates/repo/issues/issue.html
···
35
{{ if .Issue.Body }}
36
<article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article>
37
{{ end }}
38
-
<div class="flex flex-wrap gap-2 items-stretch mt-4">
39
-
{{ template "issueReactions" . }}
40
</div>
41
</section>
42
{{ end }}
···
106
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
107
</a>
108
{{ end }}
109
-
110
-
{{ define "issueReactions" }}
111
-
<div class="flex items-center gap-2">
112
-
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
113
-
{{ range $kind := .OrderedReactionKinds }}
114
-
{{ $reactionData := index $.Reactions $kind }}
115
-
{{
116
-
template "repo/fragments/reaction"
117
-
(dict
118
-
"Kind" $kind
119
-
"Count" $reactionData.Count
120
-
"IsReacted" (index $.UserReacted $kind)
121
-
"ThreadAt" $.Issue.AtUri
122
-
"Users" $reactionData.Users)
123
-
}}
124
-
{{ end }}
125
-
</div>
126
-
{{ end }}
127
-
128
129
{{ define "repoAfter" }}
130
<div class="flex flex-col gap-4 mt-4">
···
35
{{ if .Issue.Body }}
36
<article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article>
37
{{ end }}
38
+
<div class="mt-4">
39
+
{{ template "repo/fragments/reactions"
40
+
(dict "Reactions" .Reactions
41
+
"UserReacted" .UserReacted
42
+
"ThreadAt" .Issue.AtUri) }}
43
</div>
44
</section>
45
{{ end }}
···
109
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
110
</a>
111
{{ end }}
112
113
{{ define "repoAfter" }}
114
<div class="flex flex-col gap-4 mt-4">
-14
appview/pages/templates/repo/pipelines/workflow.html
-14
appview/pages/templates/repo/pipelines/workflow.html
···
12
{{ block "sidebar" . }} {{ end }}
13
</div>
14
<div class="col-span-1 md:col-span-3">
15
-
<!-- TODO(boltless): explictly check for pipeline cancel permission -->
16
-
{{ if $.RepoInfo.Roles.IsOwner }}
17
-
<div class="flex justify-between mb-2">
18
-
<div id="workflow-error" class="text-red-500 dark:text-red-400"></div>
19
-
<button
20
-
class="btn"
21
-
hx-post="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/cancel"
22
-
hx-swap="none"
23
-
{{ if (index .Pipeline.Statuses .Workflow).Latest.Status.IsFinish -}}
24
-
disabled
25
-
{{- end }}
26
-
>Cancel</button>
27
-
</div>
28
-
{{ end }}
29
{{ block "logs" . }} {{ end }}
30
</div>
31
</section>
+5
-16
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+5
-16
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
64
</article>
65
{{ end }}
66
67
-
{{ with .OrderedReactionKinds }}
68
-
<div class="flex items-center gap-2 mt-2">
69
-
{{ template "repo/fragments/reactionsPopUp" . }}
70
-
{{ range $kind := . }}
71
-
{{ $reactionData := index $.Reactions $kind }}
72
-
{{
73
-
template "repo/fragments/reaction"
74
-
(dict
75
-
"Kind" $kind
76
-
"Count" $reactionData.Count
77
-
"IsReacted" (index $.UserReacted $kind)
78
-
"ThreadAt" $.Pull.AtUri
79
-
"Users" $reactionData.Users)
80
-
}}
81
-
{{ end }}
82
</div>
83
-
{{ end }}
84
</section>
85
86
+1
-86
appview/pipelines/pipelines.go
+1
-86
appview/pipelines/pipelines.go
···
4
"bytes"
5
"context"
6
"encoding/json"
7
-
"fmt"
8
"log/slog"
9
"net/http"
10
"strings"
11
"time"
12
13
-
"tangled.org/core/api/tangled"
14
"tangled.org/core/appview/config"
15
"tangled.org/core/appview/db"
16
-
"tangled.org/core/appview/middleware"
17
-
"tangled.org/core/appview/models"
18
"tangled.org/core/appview/oauth"
19
"tangled.org/core/appview/pages"
20
"tangled.org/core/appview/reporesolver"
···
40
logger *slog.Logger
41
}
42
43
-
func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
44
r := chi.NewRouter()
45
r.Get("/", p.Index)
46
r.Get("/{pipeline}/workflow/{workflow}", p.Workflow)
47
r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs)
48
-
r.
49
-
With(mw.RepoPermissionMiddleware("repo:owner")).
50
-
Post("/{pipeline}/workflow/{workflow}/cancel", p.Cancel)
51
52
return r
53
}
···
321
}
322
}
323
}
324
-
}
325
-
326
-
func (p *Pipelines) Cancel(w http.ResponseWriter, r *http.Request) {
327
-
l := p.logger.With("handler", "Cancel")
328
-
329
-
var (
330
-
pipelineId = chi.URLParam(r, "pipeline")
331
-
workflow = chi.URLParam(r, "workflow")
332
-
)
333
-
if pipelineId == "" || workflow == "" {
334
-
http.Error(w, "missing pipeline ID or workflow", http.StatusBadRequest)
335
-
return
336
-
}
337
-
338
-
f, err := p.repoResolver.Resolve(r)
339
-
if err != nil {
340
-
l.Error("failed to get repo and knot", "err", err)
341
-
http.Error(w, "bad repo/knot", http.StatusBadRequest)
342
-
return
343
-
}
344
-
345
-
pipeline, err := func() (models.Pipeline, error) {
346
-
ps, err := db.GetPipelineStatuses(
347
-
p.db,
348
-
1,
349
-
orm.FilterEq("repo_owner", f.Did),
350
-
orm.FilterEq("repo_name", f.Name),
351
-
orm.FilterEq("knot", f.Knot),
352
-
orm.FilterEq("id", pipelineId),
353
-
)
354
-
if err != nil {
355
-
return models.Pipeline{}, err
356
-
}
357
-
if len(ps) != 1 {
358
-
return models.Pipeline{}, fmt.Errorf("wrong pipeline count %d", len(ps))
359
-
}
360
-
return ps[0], nil
361
-
}()
362
-
if err != nil {
363
-
l.Error("pipeline query failed", "err", err)
364
-
http.Error(w, "pipeline not found", http.StatusNotFound)
365
-
}
366
-
var (
367
-
spindle = f.Spindle
368
-
knot = f.Knot
369
-
rkey = pipeline.Rkey
370
-
)
371
-
372
-
if spindle == "" || knot == "" || rkey == "" {
373
-
http.Error(w, "invalid repo info", http.StatusBadRequest)
374
-
return
375
-
}
376
-
377
-
spindleClient, err := p.oauth.ServiceClient(
378
-
r,
379
-
oauth.WithService(f.Spindle),
380
-
oauth.WithLxm(tangled.PipelineCancelPipelineNSID),
381
-
oauth.WithDev(p.config.Core.Dev),
382
-
oauth.WithTimeout(time.Second*30), // workflow cleanup usually takes time
383
-
)
384
-
385
-
err = tangled.PipelineCancelPipeline(
386
-
r.Context(),
387
-
spindleClient,
388
-
&tangled.PipelineCancelPipeline_Input{
389
-
Repo: string(f.RepoAt()),
390
-
Pipeline: pipeline.AtUri().String(),
391
-
Workflow: workflow,
392
-
},
393
-
)
394
-
err = fmt.Errorf("boo! new error")
395
-
errorId := "workflow-error"
396
-
if err != nil {
397
-
l.Error("failed to cancel workflow", "err", err)
398
-
p.pages.Notice(w, errorId, "Failed to cancel workflow")
399
-
return
400
-
}
401
-
l.Debug("canceled pipeline", "uri", pipeline.AtUri())
402
}
403
404
// either a message or an error
···
4
"bytes"
5
"context"
6
"encoding/json"
7
"log/slog"
8
"net/http"
9
"strings"
10
"time"
11
12
"tangled.org/core/appview/config"
13
"tangled.org/core/appview/db"
14
"tangled.org/core/appview/oauth"
15
"tangled.org/core/appview/pages"
16
"tangled.org/core/appview/reporesolver"
···
36
logger *slog.Logger
37
}
38
39
+
func (p *Pipelines) Router() http.Handler {
40
r := chi.NewRouter()
41
r.Get("/", p.Index)
42
r.Get("/{pipeline}/workflow/{workflow}", p.Workflow)
43
r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs)
44
45
return r
46
}
···
314
}
315
}
316
}
317
}
318
319
// either a message or an error
+1
-1
appview/pulls/opengraph.go
+1
-1
appview/pulls/opengraph.go
···
242
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
243
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
244
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
245
-
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
246
if err != nil {
247
log.Printf("dolly silhouette not available (this is ok): %v", err)
248
}
···
242
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
243
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
244
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
245
+
err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor)
246
if err != nil {
247
log.Printf("dolly silhouette not available (this is ok): %v", err)
248
}
+50
-39
appview/pulls/pulls.go
+50
-39
appview/pulls/pulls.go
···
244
ResubmitCheck: resubmitResult,
245
Pipelines: m,
246
247
-
OrderedReactionKinds: models.OrderedReactionKinds,
248
-
Reactions: reactionMap,
249
-
UserReacted: userReactions,
250
251
LabelDefs: defs,
252
})
···
1241
return
1242
}
1243
1244
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1245
Collection: tangled.RepoPullNSID,
1246
Repo: user.Did,
···
1252
Repo: string(repo.RepoAt()),
1253
Branch: targetBranch,
1254
},
1255
-
Patch: patch,
1256
Source: recordPullSource,
1257
CreatedAt: time.Now().Format(time.RFC3339),
1258
},
···
1328
// apply all record creations at once
1329
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1330
for _, p := range stack {
1331
record := p.AsRecord()
1332
-
write := comatproto.RepoApplyWrites_Input_Writes_Elem{
1333
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1334
Collection: tangled.RepoPullNSID,
1335
Rkey: &p.Rkey,
···
1337
Val: &record,
1338
},
1339
},
1340
-
}
1341
-
writes = append(writes, &write)
1342
}
1343
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1344
Repo: user.Did,
···
1871
return
1872
}
1873
1874
-
var recordPullSource *tangled.RepoPull_Source
1875
-
if pull.IsBranchBased() {
1876
-
recordPullSource = &tangled.RepoPull_Source{
1877
-
Branch: pull.PullSource.Branch,
1878
-
Sha: sourceRev,
1879
-
}
1880
}
1881
-
if pull.IsForkBased() {
1882
-
repoAt := pull.PullSource.RepoAt.String()
1883
-
recordPullSource = &tangled.RepoPull_Source{
1884
-
Branch: pull.PullSource.Branch,
1885
-
Repo: &repoAt,
1886
-
Sha: sourceRev,
1887
-
}
1888
-
}
1889
1890
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1891
Collection: tangled.RepoPullNSID,
···
1893
Rkey: pull.Rkey,
1894
SwapRecord: ex.Cid,
1895
Record: &lexutil.LexiconTypeDecoder{
1896
-
Val: &tangled.RepoPull{
1897
-
Title: pull.Title,
1898
-
Target: &tangled.RepoPull_Target{
1899
-
Repo: string(repo.RepoAt()),
1900
-
Branch: pull.TargetBranch,
1901
-
},
1902
-
Patch: patch, // new patch
1903
-
Source: recordPullSource,
1904
-
CreatedAt: time.Now().Format(time.RFC3339),
1905
-
},
1906
},
1907
})
1908
if err != nil {
···
1988
}
1989
defer tx.Rollback()
1990
1991
// pds updates to make
1992
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1993
···
2021
return
2022
}
2023
2024
record := p.AsRecord()
2025
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2026
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
2027
Collection: tangled.RepoPullNSID,
···
2056
return
2057
}
2058
2059
record := np.AsRecord()
2060
-
2061
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2062
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
2063
Collection: tangled.RepoPullNSID,
···
2091
if err != nil {
2092
log.Println("failed to resubmit pull", err)
2093
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2094
-
return
2095
-
}
2096
-
2097
-
client, err := s.oauth.AuthorizedClient(r)
2098
-
if err != nil {
2099
-
log.Println("failed to authorize client")
2100
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2101
return
2102
}
2103
···
244
ResubmitCheck: resubmitResult,
245
Pipelines: m,
246
247
+
Reactions: reactionMap,
248
+
UserReacted: userReactions,
249
250
LabelDefs: defs,
251
})
···
1240
return
1241
}
1242
1243
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1244
+
if err != nil {
1245
+
log.Println("failed to upload patch", err)
1246
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1247
+
return
1248
+
}
1249
+
1250
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1251
Collection: tangled.RepoPullNSID,
1252
Repo: user.Did,
···
1258
Repo: string(repo.RepoAt()),
1259
Branch: targetBranch,
1260
},
1261
+
PatchBlob: blob.Blob,
1262
Source: recordPullSource,
1263
CreatedAt: time.Now().Format(time.RFC3339),
1264
},
···
1334
// apply all record creations at once
1335
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1336
for _, p := range stack {
1337
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(p.LatestPatch()))
1338
+
if err != nil {
1339
+
log.Println("failed to upload patch blob", err)
1340
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1341
+
return
1342
+
}
1343
+
1344
record := p.AsRecord()
1345
+
record.PatchBlob = blob.Blob
1346
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1347
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1348
Collection: tangled.RepoPullNSID,
1349
Rkey: &p.Rkey,
···
1351
Val: &record,
1352
},
1353
},
1354
+
})
1355
}
1356
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1357
Repo: user.Did,
···
1884
return
1885
}
1886
1887
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1888
+
if err != nil {
1889
+
log.Println("failed to upload patch blob", err)
1890
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1891
+
return
1892
}
1893
+
record := pull.AsRecord()
1894
+
record.PatchBlob = blob.Blob
1895
+
record.CreatedAt = time.Now().Format(time.RFC3339)
1896
1897
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1898
Collection: tangled.RepoPullNSID,
···
1900
Rkey: pull.Rkey,
1901
SwapRecord: ex.Cid,
1902
Record: &lexutil.LexiconTypeDecoder{
1903
+
Val: &record,
1904
},
1905
})
1906
if err != nil {
···
1986
}
1987
defer tx.Rollback()
1988
1989
+
client, err := s.oauth.AuthorizedClient(r)
1990
+
if err != nil {
1991
+
log.Println("failed to authorize client")
1992
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1993
+
return
1994
+
}
1995
+
1996
// pds updates to make
1997
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1998
···
2026
return
2027
}
2028
2029
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2030
+
if err != nil {
2031
+
log.Println("failed to upload patch blob", err)
2032
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2033
+
return
2034
+
}
2035
record := p.AsRecord()
2036
+
record.PatchBlob = blob.Blob
2037
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2038
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
2039
Collection: tangled.RepoPullNSID,
···
2068
return
2069
}
2070
2071
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2072
+
if err != nil {
2073
+
log.Println("failed to upload patch blob", err)
2074
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2075
+
return
2076
+
}
2077
record := np.AsRecord()
2078
+
record.PatchBlob = blob.Blob
2079
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2080
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
2081
Collection: tangled.RepoPullNSID,
···
2109
if err != nil {
2110
log.Println("failed to resubmit pull", err)
2111
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2112
return
2113
}
2114
+1
appview/repo/archive.go
+1
appview/repo/archive.go
+1
-1
appview/repo/opengraph.go
+1
-1
appview/repo/opengraph.go
···
237
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
238
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
239
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
240
-
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
241
if err != nil {
242
log.Printf("dolly silhouette not available (this is ok): %v", err)
243
}
···
237
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
238
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
239
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
240
+
err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor)
241
if err != nil {
242
log.Printf("dolly silhouette not available (this is ok): %v", err)
243
}
-14
appview/repo/settings.go
-14
appview/repo/settings.go
···
22
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
23
)
24
25
-
type tab = map[string]any
26
-
27
-
var (
28
-
// would be great to have ordered maps right about now
29
-
settingsTabs []tab = []tab{
30
-
{"Name": "general", "Icon": "sliders-horizontal"},
31
-
{"Name": "access", "Icon": "users"},
32
-
{"Name": "pipelines", "Icon": "layers-2"},
33
-
}
34
-
)
35
-
36
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
37
l := rp.logger.With("handler", "SetDefaultBranch")
38
···
262
DefaultLabels: defaultLabels,
263
SubscribedLabels: subscribedLabels,
264
ShouldSubscribeAll: shouldSubscribeAll,
265
-
Tabs: settingsTabs,
266
Tab: "general",
267
})
268
}
···
308
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
309
LoggedInUser: user,
310
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
311
-
Tabs: settingsTabs,
312
Tab: "access",
313
Collaborators: collaborators,
314
})
···
369
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
370
LoggedInUser: user,
371
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
372
-
Tabs: settingsTabs,
373
Tab: "pipelines",
374
Spindles: spindles,
375
CurrentSpindle: f.Spindle,
···
22
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
23
)
24
25
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
26
l := rp.logger.With("handler", "SetDefaultBranch")
27
···
251
DefaultLabels: defaultLabels,
252
SubscribedLabels: subscribedLabels,
253
ShouldSubscribeAll: shouldSubscribeAll,
254
Tab: "general",
255
})
256
}
···
296
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
297
LoggedInUser: user,
298
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
299
Tab: "access",
300
Collaborators: collaborators,
301
})
···
356
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
357
LoggedInUser: user,
358
RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
359
Tab: "pipelines",
360
Spindles: spindles,
361
CurrentSpindle: f.Spindle,
+26
-1
appview/reporesolver/resolver.go
+26
-1
appview/reporesolver/resolver.go
···
63
}
64
65
// get dir/ref
66
+
currentDir := extractCurrentDir(r.URL.EscapedPath())
67
ref := chi.URLParam(r, "ref")
68
69
repoAt := repo.RepoAt()
···
130
}
131
132
return repoInfo
133
+
}
134
+
135
+
// extractCurrentDir gets the current directory for markdown link resolution.
136
+
// for blob paths, returns the parent dir. for tree paths, returns the path itself.
137
+
//
138
+
// /@user/repo/blob/main/docs/README.md => docs
139
+
// /@user/repo/tree/main/docs => docs
140
+
func extractCurrentDir(fullPath string) string {
141
+
fullPath = strings.TrimPrefix(fullPath, "/")
142
+
143
+
blobPattern := regexp.MustCompile(`blob/[^/]+/(.*)$`)
144
+
if matches := blobPattern.FindStringSubmatch(fullPath); len(matches) > 1 {
145
+
return path.Dir(matches[1])
146
+
}
147
+
148
+
treePattern := regexp.MustCompile(`tree/[^/]+/(.*)$`)
149
+
if matches := treePattern.FindStringSubmatch(fullPath); len(matches) > 1 {
150
+
dir := strings.TrimSuffix(matches[1], "/")
151
+
if dir == "" {
152
+
return "."
153
+
}
154
+
return dir
155
+
}
156
+
157
+
return "."
158
}
159
160
// extractPathAfterRef gets the actual repository path
+22
appview/reporesolver/resolver_test.go
+22
appview/reporesolver/resolver_test.go
···
···
1
+
package reporesolver
2
+
3
+
import "testing"
4
+
5
+
func TestExtractCurrentDir(t *testing.T) {
6
+
tests := []struct {
7
+
path string
8
+
want string
9
+
}{
10
+
{"/@user/repo/blob/main/docs/README.md", "docs"},
11
+
{"/@user/repo/blob/main/README.md", "."},
12
+
{"/@user/repo/tree/main/docs", "docs"},
13
+
{"/@user/repo/tree/main/docs/", "docs"},
14
+
{"/@user/repo/tree/main", "."},
15
+
}
16
+
17
+
for _, tt := range tests {
18
+
if got := extractCurrentDir(tt.path); got != tt.want {
19
+
t.Errorf("extractCurrentDir(%q) = %q, want %q", tt.path, got, tt.want)
20
+
}
21
+
}
22
+
}
-17
appview/settings/settings.go
-17
appview/settings/settings.go
···
35
Config *config.Config
36
}
37
38
-
type tab = map[string]any
39
-
40
-
var (
41
-
settingsTabs []tab = []tab{
42
-
{"Name": "profile", "Icon": "user"},
43
-
{"Name": "keys", "Icon": "key"},
44
-
{"Name": "emails", "Icon": "mail"},
45
-
{"Name": "notifications", "Icon": "bell"},
46
-
{"Name": "knots", "Icon": "volleyball"},
47
-
{"Name": "spindles", "Icon": "spool"},
48
-
}
49
-
)
50
-
51
func (s *Settings) Router() http.Handler {
52
r := chi.NewRouter()
53
···
85
86
s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{
87
LoggedInUser: user,
88
-
Tabs: settingsTabs,
89
Tab: "profile",
90
})
91
}
···
104
s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{
105
LoggedInUser: user,
106
Preferences: prefs,
107
-
Tabs: settingsTabs,
108
Tab: "notifications",
109
})
110
}
···
146
s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{
147
LoggedInUser: user,
148
PubKeys: pubKeys,
149
-
Tabs: settingsTabs,
150
Tab: "keys",
151
})
152
}
···
161
s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{
162
LoggedInUser: user,
163
Emails: emails,
164
-
Tabs: settingsTabs,
165
Tab: "emails",
166
})
167
}
···
35
Config *config.Config
36
}
37
38
func (s *Settings) Router() http.Handler {
39
r := chi.NewRouter()
40
···
72
73
s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{
74
LoggedInUser: user,
75
Tab: "profile",
76
})
77
}
···
90
s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{
91
LoggedInUser: user,
92
Preferences: prefs,
93
Tab: "notifications",
94
})
95
}
···
131
s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{
132
LoggedInUser: user,
133
PubKeys: pubKeys,
134
Tab: "keys",
135
})
136
}
···
145
s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{
146
LoggedInUser: user,
147
Emails: emails,
148
Tab: "emails",
149
})
150
}
-20
appview/spindles/spindles.go
-20
appview/spindles/spindles.go
···
39
Logger *slog.Logger
40
}
41
42
-
type tab = map[string]any
43
-
44
-
var (
45
-
spindlesTabs []tab = []tab{
46
-
{"Name": "profile", "Icon": "user"},
47
-
{"Name": "keys", "Icon": "key"},
48
-
{"Name": "emails", "Icon": "mail"},
49
-
{"Name": "notifications", "Icon": "bell"},
50
-
{"Name": "knots", "Icon": "volleyball"},
51
-
{"Name": "spindles", "Icon": "spool"},
52
-
}
53
-
)
54
-
55
func (s *Spindles) Router() http.Handler {
56
r := chi.NewRouter()
57
···
83
s.Pages.Spindles(w, pages.SpindlesParams{
84
LoggedInUser: user,
85
Spindles: all,
86
-
Tabs: spindlesTabs,
87
Tab: "spindles",
88
})
89
}
···
143
Spindle: spindle,
144
Members: members,
145
Repos: repoMap,
146
-
Tabs: spindlesTabs,
147
Tab: "spindles",
148
})
149
}
···
650
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
651
if err != nil {
652
l.Error("failed to resolve member identity to handle", "err", err)
653
-
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
654
-
return
655
-
}
656
-
if memberId.Handle.IsInvalidHandle() {
657
-
l.Error("failed to resolve member identity to handle")
658
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
659
return
660
}
···
39
Logger *slog.Logger
40
}
41
42
func (s *Spindles) Router() http.Handler {
43
r := chi.NewRouter()
44
···
70
s.Pages.Spindles(w, pages.SpindlesParams{
71
LoggedInUser: user,
72
Spindles: all,
73
Tab: "spindles",
74
})
75
}
···
129
Spindle: spindle,
130
Members: members,
131
Repos: repoMap,
132
Tab: "spindles",
133
})
134
}
···
635
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
636
if err != nil {
637
l.Error("failed to resolve member identity to handle", "err", err)
638
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
639
return
640
}
+29
appview/state/manifest.go
+29
appview/state/manifest.go
···
···
1
+
package state
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
)
7
+
8
+
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
9
+
// https://www.w3.org/TR/appmanifest/
10
+
var manifestData = map[string]any{
11
+
"name": "tangled",
12
+
"description": "tightly-knit social coding.",
13
+
"icons": []map[string]string{
14
+
{
15
+
"src": "/static/logos/dolly.svg",
16
+
"sizes": "144x144",
17
+
},
18
+
},
19
+
"start_url": "/",
20
+
"id": "https://tangled.org",
21
+
"display": "standalone",
22
+
"background_color": "#111827",
23
+
"theme_color": "#111827",
24
+
}
25
+
26
+
func (p *State) WebAppManifest(w http.ResponseWriter, r *http.Request) {
27
+
w.Header().Set("Content-Type", "application/manifest+json")
28
+
json.NewEncoder(w).Encode(manifestData)
29
+
}
+6
-4
appview/state/profile.go
+6
-4
appview/state/profile.go
···
163
}
164
165
// populate commit counts in the timeline, using the punchcard
166
-
currentMonth := time.Now().Month()
167
for _, p := range profile.Punchcard.Punches {
168
-
idx := currentMonth - p.Date.Month()
169
-
if int(idx) < len(timeline.ByMonth) {
170
-
timeline.ByMonth[idx].Commits += p.Count
171
}
172
}
173
···
163
}
164
165
// populate commit counts in the timeline, using the punchcard
166
+
now := time.Now()
167
for _, p := range profile.Punchcard.Punches {
168
+
years := now.Year() - p.Date.Year()
169
+
months := int(now.Month() - p.Date.Month())
170
+
monthsAgo := years*12 + months
171
+
if monthsAgo >= 0 && monthsAgo < len(timeline.ByMonth) {
172
+
timeline.ByMonth[monthsAgo].Commits += p.Count
173
}
174
}
175
+6
-6
appview/state/router.go
+6
-6
appview/state/router.go
···
32
s.pages,
33
)
34
35
-
router.Get("/favicon.svg", s.Favicon)
36
-
router.Get("/favicon.ico", s.Favicon)
37
-
router.Get("/pwa-manifest.json", s.PWAManifest)
38
router.Get("/robots.txt", s.RobotsTxt)
39
40
userRouter := s.UserRouter(&middleware)
···
96
r.Mount("/", s.RepoRouter(mw))
97
r.Mount("/issues", s.IssuesRouter(mw))
98
r.Mount("/pulls", s.PullsRouter(mw))
99
-
r.Mount("/pipelines", s.PipelinesRouter(mw))
100
r.Mount("/labels", s.LabelsRouter())
101
102
// These routes get proxied to the knot
···
109
})
110
111
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
112
s.pages.Error404(w)
113
})
114
···
182
r.Get("/brand", s.Brand)
183
184
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
185
s.pages.Error404(w)
186
})
187
return r
···
313
return repo.Router(mw)
314
}
315
316
-
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
317
pipes := pipelines.New(
318
s.oauth,
319
s.repoResolver,
···
325
s.enforcer,
326
log.SubLogger(s.logger, "pipelines"),
327
)
328
-
return pipes.Router(mw)
329
}
330
331
func (s *State) LabelsRouter() http.Handler {
···
32
s.pages,
33
)
34
35
+
router.Get("/pwa-manifest.json", s.WebAppManifest)
36
router.Get("/robots.txt", s.RobotsTxt)
37
38
userRouter := s.UserRouter(&middleware)
···
94
r.Mount("/", s.RepoRouter(mw))
95
r.Mount("/issues", s.IssuesRouter(mw))
96
r.Mount("/pulls", s.PullsRouter(mw))
97
+
r.Mount("/pipelines", s.PipelinesRouter())
98
r.Mount("/labels", s.LabelsRouter())
99
100
// These routes get proxied to the knot
···
107
})
108
109
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
110
+
w.WriteHeader(http.StatusNotFound)
111
s.pages.Error404(w)
112
})
113
···
181
r.Get("/brand", s.Brand)
182
183
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
184
+
w.WriteHeader(http.StatusNotFound)
185
s.pages.Error404(w)
186
})
187
return r
···
313
return repo.Router(mw)
314
}
315
316
+
func (s *State) PipelinesRouter() http.Handler {
317
pipes := pipelines.New(
318
s.oauth,
319
s.repoResolver,
···
325
s.enforcer,
326
log.SubLogger(s.logger, "pipelines"),
327
)
328
+
return pipes.Router()
329
}
330
331
func (s *State) LabelsRouter() http.Handler {
-36
appview/state/state.go
-36
appview/state/state.go
···
202
return s.db.Close()
203
}
204
205
-
func (s *State) Favicon(w http.ResponseWriter, r *http.Request) {
206
-
w.Header().Set("Content-Type", "image/svg+xml")
207
-
w.Header().Set("Cache-Control", "public, max-age=31536000") // one year
208
-
w.Header().Set("ETag", `"favicon-svg-v1"`)
209
-
210
-
if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` {
211
-
w.WriteHeader(http.StatusNotModified)
212
-
return
213
-
}
214
-
215
-
s.pages.Favicon(w)
216
-
}
217
-
218
func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) {
219
w.Header().Set("Content-Type", "text/plain")
220
w.Header().Set("Cache-Control", "public, max-age=86400") // one day
···
223
Allow: /
224
`
225
w.Write([]byte(robotsTxt))
226
-
}
227
-
228
-
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
229
-
const manifestJson = `{
230
-
"name": "tangled",
231
-
"description": "tightly-knit social coding.",
232
-
"icons": [
233
-
{
234
-
"src": "/favicon.svg",
235
-
"sizes": "144x144"
236
-
}
237
-
],
238
-
"start_url": "/",
239
-
"id": "org.tangled",
240
-
241
-
"display": "standalone",
242
-
"background_color": "#111827",
243
-
"theme_color": "#111827"
244
-
}`
245
-
246
-
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
247
-
w.Header().Set("Content-Type", "application/json")
248
-
w.Write([]byte(manifestJson))
249
}
250
251
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
···
202
return s.db.Close()
203
}
204
205
func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) {
206
w.Header().Set("Content-Type", "text/plain")
207
w.Header().Set("Cache-Control", "public, max-age=86400") // one day
···
210
Allow: /
211
`
212
w.Write([]byte(robotsTxt))
213
}
214
215
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
+182
cmd/dolly/main.go
+182
cmd/dolly/main.go
···
···
1
+
package main
2
+
3
+
import (
4
+
"bytes"
5
+
"flag"
6
+
"fmt"
7
+
"image"
8
+
"image/color"
9
+
"image/png"
10
+
"os"
11
+
"path/filepath"
12
+
"strconv"
13
+
"strings"
14
+
"text/template"
15
+
16
+
"github.com/srwiley/oksvg"
17
+
"github.com/srwiley/rasterx"
18
+
"golang.org/x/image/draw"
19
+
"tangled.org/core/appview/pages"
20
+
"tangled.org/core/ico"
21
+
)
22
+
23
+
func main() {
24
+
var (
25
+
size string
26
+
fillColor string
27
+
output string
28
+
)
29
+
30
+
flag.StringVar(&size, "size", "512x512", "Output size in format WIDTHxHEIGHT (e.g., 512x512)")
31
+
flag.StringVar(&fillColor, "color", "#000000", "Fill color in hex format (e.g., #FF5733)")
32
+
flag.StringVar(&output, "output", "dolly.svg", "Output file path (format detected from extension: .svg, .png, or .ico)")
33
+
flag.Parse()
34
+
35
+
width, height, err := parseSize(size)
36
+
if err != nil {
37
+
fmt.Fprintf(os.Stderr, "Error parsing size: %v\n", err)
38
+
os.Exit(1)
39
+
}
40
+
41
+
// Detect format from file extension
42
+
ext := strings.ToLower(filepath.Ext(output))
43
+
format := strings.TrimPrefix(ext, ".")
44
+
45
+
if format != "svg" && format != "png" && format != "ico" {
46
+
fmt.Fprintf(os.Stderr, "Invalid file extension: %s. Must be .svg, .png, or .ico\n", ext)
47
+
os.Exit(1)
48
+
}
49
+
50
+
if fillColor != "currentColor" && !isValidHexColor(fillColor) {
51
+
fmt.Fprintf(os.Stderr, "Invalid color format: %s. Use hex format like #FF5733\n", fillColor)
52
+
os.Exit(1)
53
+
}
54
+
55
+
svgData, err := dolly(fillColor)
56
+
if err != nil {
57
+
fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err)
58
+
os.Exit(1)
59
+
}
60
+
61
+
// Create output directory if it doesn't exist
62
+
dir := filepath.Dir(output)
63
+
if dir != "" && dir != "." {
64
+
if err := os.MkdirAll(dir, 0755); err != nil {
65
+
fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err)
66
+
os.Exit(1)
67
+
}
68
+
}
69
+
70
+
switch format {
71
+
case "svg":
72
+
err = saveSVG(svgData, output, width, height)
73
+
case "png":
74
+
err = savePNG(svgData, output, width, height)
75
+
case "ico":
76
+
err = saveICO(svgData, output, width, height)
77
+
}
78
+
79
+
if err != nil {
80
+
fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err)
81
+
os.Exit(1)
82
+
}
83
+
84
+
fmt.Printf("Successfully generated %s (%dx%d)\n", output, width, height)
85
+
}
86
+
87
+
func dolly(hexColor string) ([]byte, error) {
88
+
tpl, err := template.New("dolly").
89
+
ParseFS(pages.Files, "templates/fragments/dolly/logo.html")
90
+
if err != nil {
91
+
return nil, err
92
+
}
93
+
94
+
var svgData bytes.Buffer
95
+
if err := tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", pages.DollyParams{
96
+
FillColor: hexColor,
97
+
}); err != nil {
98
+
return nil, err
99
+
}
100
+
101
+
return svgData.Bytes(), nil
102
+
}
103
+
104
+
func svgToImage(svgData []byte, w, h int) (image.Image, error) {
105
+
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
106
+
if err != nil {
107
+
return nil, fmt.Errorf("error parsing SVG: %v", err)
108
+
}
109
+
110
+
icon.SetTarget(0, 0, float64(w), float64(h))
111
+
rgba := image.NewRGBA(image.Rect(0, 0, w, h))
112
+
draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src)
113
+
scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())
114
+
raster := rasterx.NewDasher(w, h, scanner)
115
+
icon.Draw(raster, 1.0)
116
+
117
+
return rgba, nil
118
+
}
119
+
120
+
func parseSize(size string) (int, int, error) {
121
+
parts := strings.Split(size, "x")
122
+
if len(parts) != 2 {
123
+
return 0, 0, fmt.Errorf("invalid size format, use WIDTHxHEIGHT")
124
+
}
125
+
126
+
width, err := strconv.Atoi(parts[0])
127
+
if err != nil {
128
+
return 0, 0, fmt.Errorf("invalid width: %v", err)
129
+
}
130
+
131
+
height, err := strconv.Atoi(parts[1])
132
+
if err != nil {
133
+
return 0, 0, fmt.Errorf("invalid height: %v", err)
134
+
}
135
+
136
+
if width <= 0 || height <= 0 {
137
+
return 0, 0, fmt.Errorf("width and height must be positive")
138
+
}
139
+
140
+
return width, height, nil
141
+
}
142
+
143
+
func isValidHexColor(hex string) bool {
144
+
if len(hex) != 7 || hex[0] != '#' {
145
+
return false
146
+
}
147
+
_, err := strconv.ParseUint(hex[1:], 16, 32)
148
+
return err == nil
149
+
}
150
+
151
+
func saveSVG(svgData []byte, filepath string, _, _ int) error {
152
+
return os.WriteFile(filepath, svgData, 0644)
153
+
}
154
+
155
+
func savePNG(svgData []byte, filepath string, width, height int) error {
156
+
img, err := svgToImage(svgData, width, height)
157
+
if err != nil {
158
+
return err
159
+
}
160
+
161
+
f, err := os.Create(filepath)
162
+
if err != nil {
163
+
return err
164
+
}
165
+
defer f.Close()
166
+
167
+
return png.Encode(f, img)
168
+
}
169
+
170
+
func saveICO(svgData []byte, filepath string, width, height int) error {
171
+
img, err := svgToImage(svgData, width, height)
172
+
if err != nil {
173
+
return err
174
+
}
175
+
176
+
icoData, err := ico.ImageToIco(img)
177
+
if err != nil {
178
+
return err
179
+
}
180
+
181
+
return os.WriteFile(filepath, icoData, 0644)
182
+
}
+23
-25
docs/DOCS.md
+23
-25
docs/DOCS.md
···
2
title: Tangled docs
3
author: The Tangled Contributors
4
date: 21 Sun, Dec 2025
5
-
---
6
-
7
-
# Introduction
8
-
9
-
Tangled is a decentralized code hosting and collaboration
10
-
platform. Every component of Tangled is open-source and
11
-
self-hostable. [tangled.org](https://tangled.org) also
12
-
provides hosting and CI services that are free to use.
13
14
-
There are several models for decentralized code
15
-
collaboration platforms, ranging from ActivityPubโs
16
-
(Forgejo) federated model, to Radicleโs entirely P2P model.
17
-
Our approach attempts to be the best of both worlds by
18
-
adopting the AT Protocolโa protocol for building decentralized
19
-
social applications with a central identity
20
21
-
Our approach to this is the idea of โknotsโ. Knots are
22
-
lightweight, headless servers that enable users to host Git
23
-
repositories with ease. Knots are designed for either single
24
-
or multi-tenant use which is perfect for self-hosting on a
25
-
Raspberry Pi at home, or larger โcommunityโ servers. By
26
-
default, Tangled provides managed knots where you can host
27
-
your repositories for free.
28
29
-
The appview at tangled.org acts as a consolidated "view"
30
-
into the whole network, allowing users to access, clone and
31
-
contribute to repositories hosted across different knots
32
-
seamlessly.
33
34
# Quick start guide
35
···
2
title: Tangled docs
3
author: The Tangled Contributors
4
date: 21 Sun, Dec 2025
5
+
abstract: |
6
+
Tangled is a decentralized code hosting and collaboration
7
+
platform. Every component of Tangled is open-source and
8
+
self-hostable. [tangled.org](https://tangled.org) also
9
+
provides hosting and CI services that are free to use.
10
11
+
There are several models for decentralized code
12
+
collaboration platforms, ranging from ActivityPubโs
13
+
(Forgejo) federated model, to Radicleโs entirely P2P model.
14
+
Our approach attempts to be the best of both worlds by
15
+
adopting the AT Protocolโa protocol for building decentralized
16
+
social applications with a central identity
17
18
+
Our approach to this is the idea of โknotsโ. Knots are
19
+
lightweight, headless servers that enable users to host Git
20
+
repositories with ease. Knots are designed for either single
21
+
or multi-tenant use which is perfect for self-hosting on a
22
+
Raspberry Pi at home, or larger โcommunityโ servers. By
23
+
default, Tangled provides managed knots where you can host
24
+
your repositories for free.
25
26
+
The appview at tangled.org acts as a consolidated "view"
27
+
into the whole network, allowing users to access, clone and
28
+
contribute to repositories hosted across different knots
29
+
seamlessly.
30
+
---
31
32
# Quick start guide
33
+6
docs/logo.html
+6
docs/logo.html
+3
docs/mode.html
+3
docs/mode.html
+7
docs/search.html
+7
docs/search.html
···
···
1
+
<form action="https://google.com/search" role="search" aria-label="Sitewide" class="w-full">
2
+
<input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]">
3
+
<label>
4
+
<span style="display:none;">Search</span>
5
+
<input type="text" name="q" placeholder="Search docs ..." class="w-full font-normal">
6
+
</label>
7
+
</form>
+73
-33
docs/template.html
+73
-33
docs/template.html
···
37
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
38
39
</head>
40
-
<body class="bg-white dark:bg-gray-900 min-h-screen flex flex-col min-h-screen">
41
$for(include-before)$
42
$include-before$
43
$endfor$
44
45
$if(toc)$
46
-
<!-- mobile topbar toc -->
47
-
<details id="mobile-$idprefix$TOC" role="doc-toc" class="md:hidden bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 z-50 space-y-4 group px-6 py-4">
48
-
<summary class="cursor-pointer list-none text-sm font-semibold select-none flex gap-2 justify-between items-center dark:text-white">
49
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
50
-
<span class="group-open:hidden inline">${ menu.svg() }</span>
51
-
<span class="hidden group-open:inline">${ x.svg() }</span>
52
-
</summary>
53
-
${ table-of-contents:toc.html() }
54
-
</details>
55
<!-- desktop sidebar toc -->
56
-
<nav id="$idprefix$TOC" role="doc-toc" class="hidden md:block fixed left-0 top-0 w-80 h-screen bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 overflow-y-auto p-4 z-50">
57
-
$if(toc-title)$
58
-
<h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2>
59
-
$endif$
60
-
${ table-of-contents:toc.html() }
61
</nav>
62
$endif$
63
64
<div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col">
65
<main class="max-w-4xl w-full mx-auto p-6 flex-1">
66
$if(top)$
67
-
$-- only print title block if this is NOT the top page
68
$else$
69
$if(title)$
70
-
<header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700">
71
-
<h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1>
72
-
$if(subtitle)$
73
-
<p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p>
74
-
$endif$
75
-
$for(author)$
76
-
<p class="text-sm text-gray-500 dark:text-gray-400">$author$</p>
77
-
$endfor$
78
-
$if(date)$
79
-
<p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p>
80
-
$endif$
81
$if(abstract)$
82
-
<div class="mt-6 p-4 bg-gray-50 rounded-lg">
83
-
<div class="text-sm font-semibold text-gray-700 uppercase mb-2">$abstract-title$</div>
84
-
<div class="text-gray-700">$abstract$</div>
85
-
</div>
86
$endif$
87
-
$endif$
88
-
</header>
89
$endif$
90
<article class="prose dark:prose-invert max-w-none">
91
$body$
92
</article>
93
</main>
94
-
<nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 ">
95
<div class="max-w-4xl mx-auto px-8 py-4">
96
<div class="flex justify-between gap-4">
97
<span class="flex-1">
···
37
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
38
39
</head>
40
+
<body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh">
41
$for(include-before)$
42
$include-before$
43
$endfor$
44
45
$if(toc)$
46
+
<!-- mobile TOC trigger -->
47
+
<div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700">
48
+
<button
49
+
type="button"
50
+
popovertarget="mobile-toc-popover"
51
+
popovertargetaction="toggle"
52
+
class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white"
53
+
>
54
+
${ menu.svg() }
55
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
56
+
</button>
57
+
</div>
58
+
59
+
<div
60
+
id="mobile-toc-popover"
61
+
popover
62
+
class="mobile-toc-popover
63
+
bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700
64
+
h-full overflow-y-auto shadow-sm
65
+
px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0"
66
+
>
67
+
<div class="flex flex-col min-h-full">
68
+
<div class="flex-1 space-y-4">
69
+
<button
70
+
type="button"
71
+
popovertarget="mobile-toc-popover"
72
+
popovertargetaction="toggle"
73
+
class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4">
74
+
${ x.svg() }
75
+
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
76
+
</button>
77
+
${ logo.html() }
78
+
${ search.html() }
79
+
${ table-of-contents:toc.html() }
80
+
</div>
81
+
${ single-page:mode.html() }
82
+
</div>
83
+
</div>
84
+
85
<!-- desktop sidebar toc -->
86
+
<nav
87
+
id="$idprefix$TOC"
88
+
role="doc-toc"
89
+
class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen
90
+
bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700
91
+
p-4 z-50 overflow-y-auto">
92
+
${ logo.html() }
93
+
${ search.html() }
94
+
<div class="flex-1">
95
+
$if(toc-title)$
96
+
<h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2>
97
+
$endif$
98
+
${ table-of-contents:toc.html() }
99
+
</div>
100
+
${ single-page:mode.html() }
101
</nav>
102
$endif$
103
104
<div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col">
105
<main class="max-w-4xl w-full mx-auto p-6 flex-1">
106
$if(top)$
107
+
$-- only print title block if this is NOT the top page
108
$else$
109
$if(title)$
110
+
<header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700">
111
+
<h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1>
112
+
$if(subtitle)$
113
+
<p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p>
114
+
$endif$
115
+
$for(author)$
116
+
<p class="text-sm text-gray-500 dark:text-gray-400">$author$</p>
117
+
$endfor$
118
+
$if(date)$
119
+
<p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p>
120
+
$endif$
121
+
$endif$
122
+
</header>
123
$if(abstract)$
124
+
<article class="prose dark:prose-invert max-w-none">
125
+
$abstract$
126
+
</article>
127
$endif$
128
$endif$
129
+
130
<article class="prose dark:prose-invert max-w-none">
131
$body$
132
</article>
133
</main>
134
+
<nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
135
<div class="max-w-4xl mx-auto px-8 py-4">
136
<div class="flex justify-between gap-4">
137
<span class="flex-1">
+18
-32
flake.nix
+18
-32
flake.nix
···
76
};
77
buildGoApplication =
78
(self.callPackage "${gomod2nix}/builder" {
79
-
gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix;
80
}).buildGoApplication;
81
modules = ./nix/gomod2nix.toml;
82
sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
···
94
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
95
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
96
knot = self.callPackage ./nix/pkgs/knot.nix {};
97
-
did-method-plc = self.callPackage ./nix/pkgs/did-method-plc.nix {};
98
-
bluesky-jetstream = self.callPackage ./nix/pkgs/bluesky-jetstream.nix {};
99
-
bluesky-relay = self.callPackage ./nix/pkgs/bluesky-relay.nix {};
100
-
tap = self.callPackage ./nix/pkgs/tap.nix {};
101
});
102
in {
103
overlays.default = final: prev: {
104
-
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs did-method-plc bluesky-jetstream bluesky-relay tap;
105
};
106
107
packages = forAllSystems (system: let
···
110
staticPackages = mkPackageSet pkgs.pkgsStatic;
111
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
112
in {
113
-
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib docs did-method-plc bluesky-jetstream bluesky-relay tap;
114
115
pkgsStatic-appview = staticPackages.appview;
116
pkgsStatic-knot = staticPackages.knot;
117
pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped;
118
pkgsStatic-spindle = staticPackages.spindle;
119
pkgsStatic-sqlite-lib = staticPackages.sqlite-lib;
120
121
pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview;
122
pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot;
123
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped;
124
pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle;
125
126
treefmt-wrapper = pkgs.treefmt.withConfig {
127
settings.formatter = {
···
309
imports = [./nix/modules/spindle.nix];
310
311
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle;
312
-
services.tangled.spindle.tap-package = lib.mkDefault self.packages.${pkgs.system}.tap;
313
-
};
314
-
nixosModules.did-method-plc = {
315
-
lib,
316
-
pkgs,
317
-
...
318
-
}: {
319
-
imports = [./nix/modules/did-method-plc.nix];
320
-
services.did-method-plc.package = lib.mkDefault self.packages.${pkgs.system}.did-method-plc;
321
-
};
322
-
nixosModules.bluesky-relay = {
323
-
lib,
324
-
pkgs,
325
-
...
326
-
}: {
327
-
imports = [./nix/modules/bluesky-relay.nix];
328
-
services.bluesky-relay.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-relay;
329
-
};
330
-
nixosModules.bluesky-jetstream = {
331
-
lib,
332
-
pkgs,
333
-
...
334
-
}: {
335
-
imports = [./nix/modules/bluesky-jetstream.nix];
336
-
services.bluesky-jetstream.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-jetstream;
337
};
338
};
339
}
···
76
};
77
buildGoApplication =
78
(self.callPackage "${gomod2nix}/builder" {
79
+
gomod2nix = gomod2nix.legacyPackages.${pkgs.stdenv.hostPlatform.system}.gomod2nix;
80
}).buildGoApplication;
81
modules = ./nix/gomod2nix.toml;
82
sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
···
94
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
95
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
96
knot = self.callPackage ./nix/pkgs/knot.nix {};
97
+
dolly = self.callPackage ./nix/pkgs/dolly.nix {};
98
});
99
in {
100
overlays.default = final: prev: {
101
+
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly;
102
};
103
104
packages = forAllSystems (system: let
···
107
staticPackages = mkPackageSet pkgs.pkgsStatic;
108
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
109
in {
110
+
inherit
111
+
(packages)
112
+
appview
113
+
appview-static-files
114
+
lexgen
115
+
goat
116
+
spindle
117
+
knot
118
+
knot-unwrapped
119
+
sqlite-lib
120
+
docs
121
+
dolly
122
+
;
123
124
pkgsStatic-appview = staticPackages.appview;
125
pkgsStatic-knot = staticPackages.knot;
126
pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped;
127
pkgsStatic-spindle = staticPackages.spindle;
128
pkgsStatic-sqlite-lib = staticPackages.sqlite-lib;
129
+
pkgsStatic-dolly = staticPackages.dolly;
130
131
pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview;
132
pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot;
133
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped;
134
pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle;
135
+
pkgsCross-gnu64-pkgsStatic-dolly = crossPackages.dolly;
136
137
treefmt-wrapper = pkgs.treefmt.withConfig {
138
settings.formatter = {
···
320
imports = [./nix/modules/spindle.nix];
321
322
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle;
323
};
324
};
325
}
+88
ico/ico.go
+88
ico/ico.go
···
···
1
+
package ico
2
+
3
+
import (
4
+
"bytes"
5
+
"encoding/binary"
6
+
"fmt"
7
+
"image"
8
+
"image/png"
9
+
)
10
+
11
+
type IconDir struct {
12
+
Reserved uint16 // must be 0
13
+
Type uint16 // 1 for ICO, 2 for CUR
14
+
Count uint16 // number of images
15
+
}
16
+
17
+
type IconDirEntry struct {
18
+
Width uint8 // 0 means 256
19
+
Height uint8 // 0 means 256
20
+
ColorCount uint8
21
+
Reserved uint8 // must be 0
22
+
ColorPlanes uint16 // 0 or 1
23
+
BitsPerPixel uint16
24
+
SizeInBytes uint32
25
+
Offset uint32
26
+
}
27
+
28
+
func ImageToIco(img image.Image) ([]byte, error) {
29
+
// encode image as png
30
+
var pngBuf bytes.Buffer
31
+
if err := png.Encode(&pngBuf, img); err != nil {
32
+
return nil, fmt.Errorf("failed to encode PNG: %w", err)
33
+
}
34
+
pngData := pngBuf.Bytes()
35
+
36
+
// get image dimensions
37
+
bounds := img.Bounds()
38
+
width := bounds.Dx()
39
+
height := bounds.Dy()
40
+
41
+
// prepare output buffer
42
+
var icoBuf bytes.Buffer
43
+
44
+
iconDir := IconDir{
45
+
Reserved: 0,
46
+
Type: 1, // ICO format
47
+
Count: 1, // One image
48
+
}
49
+
50
+
w := uint8(width)
51
+
h := uint8(height)
52
+
53
+
// width/height of 256 should be stored as 0
54
+
if width == 256 {
55
+
w = 0
56
+
}
57
+
if height == 256 {
58
+
h = 0
59
+
}
60
+
61
+
iconDirEntry := IconDirEntry{
62
+
Width: w,
63
+
Height: h,
64
+
ColorCount: 0, // 0 for PNG (32-bit)
65
+
Reserved: 0,
66
+
ColorPlanes: 1,
67
+
BitsPerPixel: 32, // PNG with alpha
68
+
SizeInBytes: uint32(len(pngData)),
69
+
Offset: 6 + 16, // Size of ICONDIR + ICONDIRENTRY
70
+
}
71
+
72
+
// write IconDir
73
+
if err := binary.Write(&icoBuf, binary.LittleEndian, iconDir); err != nil {
74
+
return nil, fmt.Errorf("failed to write ICONDIR: %w", err)
75
+
}
76
+
77
+
// write IconDirEntry
78
+
if err := binary.Write(&icoBuf, binary.LittleEndian, iconDirEntry); err != nil {
79
+
return nil, fmt.Errorf("failed to write ICONDIRENTRY: %w", err)
80
+
}
81
+
82
+
// write PNG data directly
83
+
if _, err := icoBuf.Write(pngData); err != nil {
84
+
return nil, fmt.Errorf("failed to write PNG data: %w", err)
85
+
}
86
+
87
+
return icoBuf.Bytes(), nil
88
+
}
+1
input.css
+1
input.css
-33
lexicons/pipeline/cancelPipeline.json
-33
lexicons/pipeline/cancelPipeline.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.pipeline.cancelPipeline",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Cancel a running pipeline",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": ["repo", "pipeline", "workflow"],
13
-
"properties": {
14
-
"repo": {
15
-
"type": "string",
16
-
"format": "at-uri",
17
-
"description": "repo at-uri, spindle can't resolve repo from pipeline at-uri yet"
18
-
},
19
-
"pipeline": {
20
-
"type": "string",
21
-
"format": "at-uri",
22
-
"description": "pipeline at-uri"
23
-
},
24
-
"workflow": {
25
-
"type": "string",
26
-
"description": "workflow name"
27
-
}
28
-
}
29
-
}
30
-
}
31
-
}
32
-
}
33
-
}
···
+10
-2
lexicons/pulls/pull.json
+10
-2
lexicons/pulls/pull.json
···
12
"required": [
13
"target",
14
"title",
15
+
"patchBlob",
16
"createdAt"
17
],
18
"properties": {
···
27
"type": "string"
28
},
29
"patch": {
30
+
"type": "string",
31
+
"description": "(deprecated) use patchBlob instead"
32
+
},
33
+
"patchBlob": {
34
+
"type": "blob",
35
+
"accept": [
36
+
"text/x-patch"
37
+
],
38
+
"description": "patch content"
39
},
40
"source": {
41
"type": "ref",
-64
nix/modules/bluesky-jetstream.nix
-64
nix/modules/bluesky-jetstream.nix
···
1
-
{
2
-
config,
3
-
pkgs,
4
-
lib,
5
-
...
6
-
}: let
7
-
cfg = config.services.bluesky-jetstream;
8
-
in
9
-
with lib; {
10
-
options.services.bluesky-jetstream = {
11
-
enable = mkEnableOption "jetstream server";
12
-
package = mkPackageOption pkgs "bluesky-jetstream" {};
13
-
14
-
# dataDir = mkOption {
15
-
# type = types.str;
16
-
# default = "/var/lib/jetstream";
17
-
# description = "directory to store data (pebbleDB)";
18
-
# };
19
-
livenessTtl = mkOption {
20
-
type = types.int;
21
-
default = 15;
22
-
description = "time to restart when no event detected (seconds)";
23
-
};
24
-
websocketUrl = mkOption {
25
-
type = types.str;
26
-
default = "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos";
27
-
description = "full websocket path to the ATProto SubscribeRepos XRPC endpoint";
28
-
};
29
-
};
30
-
config = mkIf cfg.enable {
31
-
systemd.services.bluesky-jetstream = {
32
-
description = "bluesky jetstream";
33
-
after = ["network.target" "pds.service"];
34
-
wantedBy = ["multi-user.target"];
35
-
36
-
serviceConfig = {
37
-
User = "jetstream";
38
-
Group = "jetstream";
39
-
StateDirectory = "jetstream";
40
-
StateDirectoryMode = "0755";
41
-
# preStart = ''
42
-
# mkdir -p "${cfg.dataDir}"
43
-
# chown -R jetstream:jetstream "${cfg.dataDir}"
44
-
# '';
45
-
# WorkingDirectory = cfg.dataDir;
46
-
Environment = [
47
-
"JETSTREAM_DATA_DIR=/var/lib/jetstream/data"
48
-
"JETSTREAM_LIVENESS_TTL=${toString cfg.livenessTtl}s"
49
-
"JETSTREAM_WS_URL=${cfg.websocketUrl}"
50
-
];
51
-
ExecStart = getExe cfg.package;
52
-
Restart = "always";
53
-
RestartSec = 5;
54
-
};
55
-
};
56
-
users = {
57
-
users.jetstream = {
58
-
group = "jetstream";
59
-
isSystemUser = true;
60
-
};
61
-
groups.jetstream = {};
62
-
};
63
-
};
64
-
}
···
-48
nix/modules/bluesky-relay.nix
-48
nix/modules/bluesky-relay.nix
···
1
-
{
2
-
config,
3
-
pkgs,
4
-
lib,
5
-
...
6
-
}: let
7
-
cfg = config.services.bluesky-relay;
8
-
in
9
-
with lib; {
10
-
options.services.bluesky-relay = {
11
-
enable = mkEnableOption "relay server";
12
-
package = mkPackageOption pkgs "bluesky-relay" {};
13
-
};
14
-
config = mkIf cfg.enable {
15
-
systemd.services.bluesky-relay = {
16
-
description = "bluesky relay";
17
-
after = ["network.target" "pds.service"];
18
-
wantedBy = ["multi-user.target"];
19
-
20
-
serviceConfig = {
21
-
User = "relay";
22
-
Group = "relay";
23
-
StateDirectory = "relay";
24
-
StateDirectoryMode = "0755";
25
-
Environment = [
26
-
"RELAY_ADMIN_PASSWORD=password"
27
-
"RELAY_PLC_HOST=https://plc.tngl.boltless.dev"
28
-
"DATABASE_URL=sqlite:///var/lib/relay/relay.sqlite"
29
-
"RELAY_IP_BIND=:2470"
30
-
"RELAY_PERSIST_DIR=/var/lib/relay"
31
-
"RELAY_DISABLE_REQUEST_CRAWL=0"
32
-
"RELAY_INITIAL_SEQ_NUMBER=1"
33
-
"RELAY_ALLOW_INSECURE_HOSTS=1"
34
-
];
35
-
ExecStart = "${getExe cfg.package} serve";
36
-
Restart = "always";
37
-
RestartSec = 5;
38
-
};
39
-
};
40
-
users = {
41
-
users.relay = {
42
-
group = "relay";
43
-
isSystemUser = true;
44
-
};
45
-
groups.relay = {};
46
-
};
47
-
};
48
-
}
···
-76
nix/modules/did-method-plc.nix
-76
nix/modules/did-method-plc.nix
···
1
-
{
2
-
config,
3
-
pkgs,
4
-
lib,
5
-
...
6
-
}: let
7
-
cfg = config.services.did-method-plc;
8
-
in
9
-
with lib; {
10
-
options.services.did-method-plc = {
11
-
enable = mkEnableOption "did-method-plc server";
12
-
package = mkPackageOption pkgs "did-method-plc" {};
13
-
};
14
-
config = mkIf cfg.enable {
15
-
services.postgresql = {
16
-
enable = true;
17
-
package = pkgs.postgresql_14;
18
-
ensureDatabases = ["plc"];
19
-
ensureUsers = [
20
-
{
21
-
name = "pg";
22
-
# ensurePermissions."DATABASE plc" = "ALL PRIVILEGES";
23
-
}
24
-
];
25
-
authentication = ''
26
-
local all all trust
27
-
host all all 127.0.0.1/32 trust
28
-
'';
29
-
};
30
-
systemd.services.did-method-plc = {
31
-
description = "did-method-plc";
32
-
33
-
after = ["postgresql.service"];
34
-
wants = ["postgresql.service"];
35
-
wantedBy = ["multi-user.target"];
36
-
37
-
environment = let
38
-
db_creds_json = builtins.toJSON {
39
-
username = "pg";
40
-
password = "";
41
-
host = "127.0.0.1";
42
-
port = 5432;
43
-
};
44
-
in {
45
-
# TODO: inherit from config
46
-
DEBUG_MODE = "1";
47
-
LOG_ENABLED = "true";
48
-
LOG_LEVEL = "debug";
49
-
LOG_DESTINATION = "1";
50
-
ENABLE_MIGRATIONS = "true";
51
-
DB_CREDS_JSON = db_creds_json;
52
-
DB_MIGRATE_CREDS_JSON = db_creds_json;
53
-
PLC_VERSION = "0.0.1";
54
-
PORT = "8080";
55
-
};
56
-
57
-
serviceConfig = {
58
-
ExecStart = getExe cfg.package;
59
-
User = "plc";
60
-
Group = "plc";
61
-
StateDirectory = "plc";
62
-
StateDirectoryMode = "0755";
63
-
Restart = "always";
64
-
65
-
# Hardening
66
-
};
67
-
};
68
-
users = {
69
-
users.plc = {
70
-
group = "plc";
71
-
isSystemUser = true;
72
-
};
73
-
groups.plc = {};
74
-
};
75
-
};
76
-
}
···
+12
-39
nix/modules/spindle.nix
+12
-39
nix/modules/spindle.nix
···
17
type = types.package;
18
description = "Package to use for the spindle";
19
};
20
-
tap-package = mkOption {
21
-
type = types.package;
22
-
description = "Package to use for the spindle";
23
-
};
24
-
25
-
atpRelayUrl = mkOption {
26
-
type = types.str;
27
-
default = "https://relay1.us-east.bsky.network";
28
-
description = "atproto relay";
29
-
};
30
31
server = {
32
listenAddr = mkOption {
···
35
description = "Address to listen on";
36
};
37
38
-
stateDir = mkOption {
39
type = types.path;
40
-
default = "/var/lib/spindle";
41
-
description = "Tangled spindle data directory";
42
};
43
44
hostname = mkOption {
···
51
type = types.str;
52
default = "https://plc.directory";
53
description = "atproto PLC directory";
54
};
55
56
dev = mkOption {
···
118
config = mkIf cfg.enable {
119
virtualisation.docker.enable = true;
120
121
-
systemd.services.spindle-tap = {
122
-
description = "spindle tap service";
123
-
after = ["network.target" "docker.service"];
124
-
wantedBy = ["multi-user.target"];
125
-
serviceConfig = {
126
-
LogsDirectory = "spindle-tap";
127
-
StateDirectory = "spindle-tap";
128
-
Environment = [
129
-
"TAP_BIND=:2480"
130
-
"TAP_PLC_URL=${cfg.server.plcUrl}"
131
-
"TAP_RELAY_URL=${cfg.atpRelayUrl}"
132
-
"TAP_DATABASE_URL=sqlite:///var/lib/spindle-tap/tap.db"
133
-
"TAP_RETRY_TIMEOUT=3s"
134
-
"TAP_COLLECTION_FILTERS=${concatStringsSep "," [
135
-
"sh.tangled.repo"
136
-
"sh.tangled.repo.collaborator"
137
-
"sh.tangled.spindle.member"
138
-
]}"
139
-
];
140
-
ExecStart = "${getExe cfg.tap-package} run";
141
-
};
142
-
};
143
-
144
systemd.services.spindle = {
145
description = "spindle service";
146
-
after = ["network.target" "docker.service" "spindle-tap.service"];
147
wantedBy = ["multi-user.target"];
148
serviceConfig = {
149
LogsDirectory = "spindle";
150
StateDirectory = "spindle";
151
Environment = [
152
"SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
153
-
"SPINDLE_SERVER_DATA_DIR=${cfg.server.stateDir}"
154
"SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
155
"SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}"
156
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
157
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
158
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
···
160
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
161
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
162
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
163
-
"SPINDLE_SERVER_TAP_URL=http://localhost:2480"
164
"SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
165
"SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
166
];
···
17
type = types.package;
18
description = "Package to use for the spindle";
19
};
20
21
server = {
22
listenAddr = mkOption {
···
25
description = "Address to listen on";
26
};
27
28
+
dbPath = mkOption {
29
type = types.path;
30
+
default = "/var/lib/spindle/spindle.db";
31
+
description = "Path to the database file";
32
};
33
34
hostname = mkOption {
···
41
type = types.str;
42
default = "https://plc.directory";
43
description = "atproto PLC directory";
44
+
};
45
+
46
+
jetstreamEndpoint = mkOption {
47
+
type = types.str;
48
+
default = "wss://jetstream1.us-west.bsky.network/subscribe";
49
+
description = "Jetstream endpoint to subscribe to";
50
};
51
52
dev = mkOption {
···
114
config = mkIf cfg.enable {
115
virtualisation.docker.enable = true;
116
117
systemd.services.spindle = {
118
description = "spindle service";
119
+
after = ["network.target" "docker.service"];
120
wantedBy = ["multi-user.target"];
121
serviceConfig = {
122
LogsDirectory = "spindle";
123
StateDirectory = "spindle";
124
Environment = [
125
"SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
126
+
"SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}"
127
"SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
128
"SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}"
129
+
"SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
130
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
131
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
132
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
···
134
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
135
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
136
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
137
"SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
138
"SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
139
];
+6
-1
nix/pkgs/appview-static-files.nix
+6
-1
nix/pkgs/appview-static-files.nix
···
8
actor-typeahead-src,
9
sqlite-lib,
10
tailwindcss,
11
src,
12
}:
13
runCommandLocal "appview-static-files" {
···
17
(allow file-read* (subpath "/System/Library/OpenSSL"))
18
'';
19
} ''
20
-
mkdir -p $out/{fonts,icons} && cd $out
21
cp -f ${htmx-src} htmx.min.js
22
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
23
cp -rf ${lucide-src}/*.svg icons/
···
26
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
27
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
28
cp -f ${actor-typeahead-src}/actor-typeahead.js .
29
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
30
# for whatever reason (produces broken css), so we are doing this instead
31
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
···
8
actor-typeahead-src,
9
sqlite-lib,
10
tailwindcss,
11
+
dolly,
12
src,
13
}:
14
runCommandLocal "appview-static-files" {
···
18
(allow file-read* (subpath "/System/Library/OpenSSL"))
19
'';
20
} ''
21
+
mkdir -p $out/{fonts,icons,logos} && cd $out
22
cp -f ${htmx-src} htmx.min.js
23
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
24
cp -rf ${lucide-src}/*.svg icons/
···
27
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
28
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
29
cp -f ${actor-typeahead-src}/actor-typeahead.js .
30
+
31
+
${dolly}/bin/dolly -output logos/dolly.png -size 180x180
32
+
${dolly}/bin/dolly -output logos/dolly.ico -size 48x48
33
+
${dolly}/bin/dolly -output logos/dolly.svg -color currentColor
34
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
35
# for whatever reason (produces broken css), so we are doing this instead
36
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
-20
nix/pkgs/bluesky-jetstream.nix
-20
nix/pkgs/bluesky-jetstream.nix
···
1
-
{
2
-
buildGoModule,
3
-
fetchFromGitHub,
4
-
}:
5
-
buildGoModule {
6
-
pname = "bluesky-jetstream";
7
-
version = "0.1.0";
8
-
src = fetchFromGitHub {
9
-
owner = "bluesky-social";
10
-
repo = "jetstream";
11
-
rev = "7d7efa58d7f14101a80ccc4f1085953948b7d5de";
12
-
sha256 = "sha256-1e9SL/8gaDPMA4YZed51ffzgpkptbMd0VTbTTDbPTFw=";
13
-
};
14
-
subPackages = ["cmd/jetstream"];
15
-
vendorHash = "sha256-/21XJQH6fo9uPzlABUAbdBwt1O90odmppH6gXu2wkiQ=";
16
-
doCheck = false;
17
-
meta = {
18
-
mainProgram = "jetstream";
19
-
};
20
-
}
···
-20
nix/pkgs/bluesky-relay.nix
-20
nix/pkgs/bluesky-relay.nix
···
1
-
{
2
-
buildGoModule,
3
-
fetchFromGitHub,
4
-
}:
5
-
buildGoModule {
6
-
pname = "bluesky-relay";
7
-
version = "0.1.0";
8
-
src = fetchFromGitHub {
9
-
owner = "boltlessengineer";
10
-
repo = "indigo";
11
-
rev = "7fe70a304d795b998f354d2b7b2050b909709c99";
12
-
sha256 = "sha256-+h34x67cqH5t30+8rua53/ucvbn3BanrmH0Og3moHok=";
13
-
};
14
-
subPackages = ["cmd/relay"];
15
-
vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8=";
16
-
doCheck = false;
17
-
meta = {
18
-
mainProgram = "relay";
19
-
};
20
-
}
···
-65
nix/pkgs/did-method-plc.nix
-65
nix/pkgs/did-method-plc.nix
···
1
-
# inspired by https://github.com/NixOS/nixpkgs/blob/333bfb7c258fab089a834555ea1c435674c459b4/pkgs/by-name/ga/gatsby-cli/package.nix
2
-
{
3
-
lib,
4
-
stdenv,
5
-
fetchFromGitHub,
6
-
fetchYarnDeps,
7
-
yarnConfigHook,
8
-
yarnBuildHook,
9
-
nodejs,
10
-
makeBinaryWrapper,
11
-
}:
12
-
stdenv.mkDerivation (finalAttrs: {
13
-
pname = "did-method-plc";
14
-
version = "0.0.1";
15
-
16
-
src = fetchFromGitHub {
17
-
owner = "did-method-plc";
18
-
repo = "did-method-plc";
19
-
rev = "158ba5535ac3da4fd4309954bde41deab0b45972";
20
-
sha256 = "sha256-O5smubbrnTDMCvL6iRyMXkddr5G7YHxkQRVMRULHanQ=";
21
-
};
22
-
postPatch = ''
23
-
# remove dd-trace dependency
24
-
sed -i '3d' packages/server/service/index.js
25
-
'';
26
-
27
-
yarnOfflineCache = fetchYarnDeps {
28
-
yarnLock = finalAttrs.src + "/yarn.lock";
29
-
hash = "sha256-g8GzaAbWSnWwbQjJMV2DL5/ZlWCCX0sRkjjvX3tqU4Y=";
30
-
};
31
-
32
-
nativeBuildInputs = [
33
-
yarnConfigHook
34
-
yarnBuildHook
35
-
nodejs
36
-
makeBinaryWrapper
37
-
];
38
-
yarnBuildScript = "lerna";
39
-
yarnBuildFlags = [
40
-
"run"
41
-
"build"
42
-
"--scope"
43
-
"@did-plc/server"
44
-
"--include-dependencies"
45
-
];
46
-
47
-
installPhase = ''
48
-
runHook preInstall
49
-
50
-
mkdir -p $out/lib/node_modules/
51
-
mv packages/ $out/lib/packages/
52
-
mv node_modules/* $out/lib/node_modules/
53
-
54
-
makeWrapper ${lib.getExe nodejs} $out/bin/plc \
55
-
--add-flags $out/lib/packages/server/service/index.js \
56
-
--add-flags --enable-source-maps \
57
-
--set NODE_PATH $out/lib/node_modules
58
-
59
-
runHook postInstall
60
-
'';
61
-
62
-
meta = {
63
-
mainProgram = "plc";
64
-
};
65
-
})
···
+17
-1
nix/pkgs/docs.nix
+17
-1
nix/pkgs/docs.nix
···
5
inter-fonts-src,
6
ibm-plex-mono-src,
7
lucide-src,
8
src,
9
}:
10
runCommandLocal "docs" {} ''
···
18
# icons
19
cp -rf ${lucide-src}/*.svg working/
20
21
-
# content
22
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
23
-o $out/ \
24
-t chunkedhtml \
25
--variable toc \
26
--toc-depth=2 \
27
--css=stylesheet.css \
28
--chunk-template="%i.html" \
29
--highlight-style=working/highlight.theme \
30
--template=working/template.html
31
···
5
inter-fonts-src,
6
ibm-plex-mono-src,
7
lucide-src,
8
+
dolly,
9
src,
10
}:
11
runCommandLocal "docs" {} ''
···
19
# icons
20
cp -rf ${lucide-src}/*.svg working/
21
22
+
# logo
23
+
${dolly}/bin/dolly -output working/dolly.svg -color currentColor
24
+
25
+
# content - chunked
26
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
27
-o $out/ \
28
-t chunkedhtml \
29
--variable toc \
30
+
--variable-json single-page=false \
31
--toc-depth=2 \
32
--css=stylesheet.css \
33
--chunk-template="%i.html" \
34
+
--highlight-style=working/highlight.theme \
35
+
--template=working/template.html
36
+
37
+
# content - single page
38
+
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
39
+
-o $out/single-page.html \
40
+
--toc \
41
+
--variable toc \
42
+
--variable single-page \
43
+
--toc-depth=2 \
44
+
--css=stylesheet.css \
45
--highlight-style=working/highlight.theme \
46
--template=working/template.html
47
+21
nix/pkgs/dolly.nix
+21
nix/pkgs/dolly.nix
···
···
1
+
{
2
+
buildGoApplication,
3
+
modules,
4
+
src,
5
+
}:
6
+
buildGoApplication {
7
+
pname = "dolly";
8
+
version = "0.1.0";
9
+
inherit src modules;
10
+
11
+
# patch the static dir
12
+
postUnpack = ''
13
+
pushd source
14
+
mkdir -p appview/pages/static
15
+
touch appview/pages/static/x
16
+
popd
17
+
'';
18
+
19
+
doCheck = false;
20
+
subPackages = ["cmd/dolly"];
21
+
}
-20
nix/pkgs/tap.nix
-20
nix/pkgs/tap.nix
···
1
-
{
2
-
buildGoModule,
3
-
fetchFromGitHub,
4
-
}:
5
-
buildGoModule {
6
-
pname = "tap";
7
-
version = "0.1.0";
8
-
src = fetchFromGitHub {
9
-
owner = "bluesky-social";
10
-
repo = "indigo";
11
-
rev = "498ecb9693e8ae050f73234c86f340f51ad896a9";
12
-
sha256 = "sha256-KASCdwkg/hlKBt7RTW3e3R5J3hqJkphoarFbaMgtN1k=";
13
-
};
14
-
subPackages = ["cmd/tap"];
15
-
vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8=";
16
-
doCheck = false;
17
-
meta = {
18
-
mainProgram = "tap";
19
-
};
20
-
}
···
+2
-8
nix/vm.nix
+2
-8
nix/vm.nix
···
19
20
plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory";
21
jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe";
22
-
relayUrl = envVarOr "TANGLED_VM_RELAY_URL" "https://relay1.us-east.bsky.network";
23
in
24
nixpkgs.lib.nixosSystem {
25
inherit system;
···
58
host.port = 6555;
59
guest.port = 6555;
60
}
61
-
{
62
-
from = "host";
63
-
host.port = 6556;
64
-
guest.port = 2480;
65
-
}
66
];
67
sharedDirectories = {
68
# We can't use the 9p mounts directly for most of these
···
101
};
102
services.tangled.spindle = {
103
enable = true;
104
-
atpRelayUrl = relayUrl;
105
server = {
106
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
107
hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
108
plcUrl = plcUrl;
109
listenAddr = "0.0.0.0:6555";
110
dev = true;
111
queueSize = 100;
···
140
};
141
in {
142
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir;
143
-
spindle = mkDataSyncScripts "/mnt/spindle-data" config.services.tangled.spindle.server.stateDir;
144
};
145
})
146
];
···
19
20
plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory";
21
jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe";
22
in
23
nixpkgs.lib.nixosSystem {
24
inherit system;
···
57
host.port = 6555;
58
guest.port = 6555;
59
}
60
];
61
sharedDirectories = {
62
# We can't use the 9p mounts directly for most of these
···
95
};
96
services.tangled.spindle = {
97
enable = true;
98
server = {
99
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
100
hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
101
plcUrl = plcUrl;
102
+
jetstreamEndpoint = jetstream;
103
listenAddr = "0.0.0.0:6555";
104
dev = true;
105
queueSize = 100;
···
134
};
135
in {
136
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir;
137
+
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath);
138
};
139
})
140
];
-10
orm/orm.go
-10
orm/orm.go
···
20
}
21
defer tx.Rollback()
22
23
-
_, err = tx.Exec(`
24
-
create table if not exists migrations (
25
-
id integer primary key autoincrement,
26
-
name text unique
27
-
);
28
-
`)
29
-
if err != nil {
30
-
return fmt.Errorf("creating migrations table: %w", err)
31
-
}
32
-
33
var exists bool
34
err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists)
35
if err != nil {
-52
rbac2/bytesadapter/adapter.go
-52
rbac2/bytesadapter/adapter.go
···
1
-
package bytesadapter
2
-
3
-
import (
4
-
"bufio"
5
-
"bytes"
6
-
"errors"
7
-
"strings"
8
-
9
-
"github.com/casbin/casbin/v2/model"
10
-
"github.com/casbin/casbin/v2/persist"
11
-
)
12
-
13
-
var (
14
-
errNotImplemented = errors.New("not implemented")
15
-
)
16
-
17
-
type Adapter struct {
18
-
b []byte
19
-
}
20
-
21
-
var _ persist.Adapter = &Adapter{}
22
-
23
-
func NewAdapter(b []byte) *Adapter {
24
-
return &Adapter{b}
25
-
}
26
-
27
-
func (a *Adapter) LoadPolicy(model model.Model) error {
28
-
scanner := bufio.NewScanner(bytes.NewReader(a.b))
29
-
for scanner.Scan() {
30
-
line := strings.TrimSpace(scanner.Text())
31
-
if err := persist.LoadPolicyLine(line, model); err != nil {
32
-
return err
33
-
}
34
-
}
35
-
return scanner.Err()
36
-
}
37
-
38
-
func (a *Adapter) AddPolicy(sec string, ptype string, rule []string) error {
39
-
return errNotImplemented
40
-
}
41
-
42
-
func (a *Adapter) RemoveFilteredPolicy(sec string, ptype string, fieldIndex int, fieldValues ...string) error {
43
-
return errNotImplemented
44
-
}
45
-
46
-
func (a *Adapter) RemovePolicy(sec string, ptype string, rule []string) error {
47
-
return errNotImplemented
48
-
}
49
-
50
-
func (a *Adapter) SavePolicy(model model.Model) error {
51
-
return errNotImplemented
52
-
}
···
-139
rbac2/rbac2.go
-139
rbac2/rbac2.go
···
1
-
package rbac2
2
-
3
-
import (
4
-
"database/sql"
5
-
_ "embed"
6
-
"fmt"
7
-
8
-
adapter "github.com/Blank-Xu/sql-adapter"
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"github.com/casbin/casbin/v2"
11
-
"github.com/casbin/casbin/v2/model"
12
-
"github.com/casbin/casbin/v2/util"
13
-
"tangled.org/core/rbac2/bytesadapter"
14
-
)
15
-
16
-
const (
17
-
Model = `
18
-
[request_definition]
19
-
r = sub, dom, obj, act
20
-
21
-
[policy_definition]
22
-
p = sub, dom, obj, act
23
-
24
-
[role_definition]
25
-
g = _, _, _
26
-
27
-
[policy_effect]
28
-
e = some(where (p.eft == allow))
29
-
30
-
[matchers]
31
-
m = g(r.sub, p.sub, r.dom) && keyMatch4(r.dom, p.dom) && r.obj == p.obj && r.act == p.act
32
-
`
33
-
)
34
-
35
-
type Enforcer struct {
36
-
e *casbin.Enforcer
37
-
}
38
-
39
-
//go:embed tangled_policy.csv
40
-
var tangledPolicy []byte
41
-
42
-
func NewEnforcer(path string) (*Enforcer, error) {
43
-
db, err := sql.Open("sqlite3", path+"?_foreign_keys=1")
44
-
if err != nil {
45
-
return nil, err
46
-
}
47
-
return NewEnforcerWithDB(db)
48
-
}
49
-
50
-
func NewEnforcerWithDB(db *sql.DB) (*Enforcer, error) {
51
-
m, err := model.NewModelFromString(Model)
52
-
if err != nil {
53
-
return nil, err
54
-
}
55
-
56
-
a, err := adapter.NewAdapter(db, "sqlite3", "acl")
57
-
if err != nil {
58
-
return nil, err
59
-
}
60
-
61
-
// // PATCH: create unique index to make `AddPoliciesEx` work
62
-
// _, err = db.Exec(fmt.Sprintf(
63
-
// `create unique index if not exists uq_%[1]s on %[1]s (p_type,v0,v1,v2,v3,v4,v5);`,
64
-
// tableName,
65
-
// ))
66
-
// if err != nil {
67
-
// return nil, err
68
-
// }
69
-
70
-
e, _ := casbin.NewEnforcer() // NewEnforcer() without param won't return error
71
-
// e.EnableLog(true)
72
-
73
-
// NOTE: casbin clears the model on init, so we should intialize with temporary adapter first
74
-
// and then override the adapter to sql-adapter.
75
-
// `e.SetModel(m)` after init doesn't work for some reason
76
-
if err := e.InitWithModelAndAdapter(m, bytesadapter.NewAdapter(tangledPolicy)); err != nil {
77
-
return nil, err
78
-
}
79
-
80
-
// load dynamic policy from db
81
-
e.EnableAutoSave(false)
82
-
if err := a.LoadPolicy(e.GetModel()); err != nil {
83
-
return nil, err
84
-
}
85
-
e.AddNamedDomainMatchingFunc("g", "keyMatch4", util.KeyMatch4)
86
-
e.BuildRoleLinks()
87
-
e.SetAdapter(a)
88
-
e.EnableAutoSave(true)
89
-
90
-
return &Enforcer{e}, nil
91
-
}
92
-
93
-
// CaptureModel returns copy of current model. Used for testing
94
-
func (e *Enforcer) CaptureModel() model.Model {
95
-
return e.e.GetModel().Copy()
96
-
}
97
-
98
-
func (e *Enforcer) hasImplicitRoleForUser(name string, role string, domain ...string) (bool, error) {
99
-
roles, err := e.e.GetImplicitRolesForUser(name, domain...)
100
-
if err != nil {
101
-
return false, err
102
-
}
103
-
for _, r := range roles {
104
-
if r == role {
105
-
return true, nil
106
-
}
107
-
}
108
-
return false, nil
109
-
}
110
-
111
-
// setRoleForUser sets single user role for specified domain.
112
-
// All existing users with that role will be removed.
113
-
func (e *Enforcer) setRoleForUser(name string, role string, domain ...string) error {
114
-
currentUsers, err := e.e.GetUsersForRole(role, domain...)
115
-
if err != nil {
116
-
return err
117
-
}
118
-
119
-
for _, oldUser := range currentUsers {
120
-
_, err = e.e.DeleteRoleForUser(oldUser, role, domain...)
121
-
if err != nil {
122
-
return err
123
-
}
124
-
}
125
-
126
-
_, err = e.e.AddRoleForUser(name, role, domain...)
127
-
return err
128
-
}
129
-
130
-
// validateAtUri enforeces AT-URI to have valid did as authority and match collection NSID.
131
-
func validateAtUri(uri syntax.ATURI, expected string) error {
132
-
if !uri.Authority().IsDID() {
133
-
return fmt.Errorf("expected at-uri with did")
134
-
}
135
-
if expected != "" && uri.Collection().String() != expected {
136
-
return fmt.Errorf("incorrect repo at-uri collection nsid '%s' (expected '%s')", uri.Collection(), expected)
137
-
}
138
-
return nil
139
-
}
···
-150
rbac2/rbac2_test.go
-150
rbac2/rbac2_test.go
···
1
-
package rbac2_test
2
-
3
-
import (
4
-
"database/sql"
5
-
"testing"
6
-
7
-
"github.com/bluesky-social/indigo/atproto/syntax"
8
-
_ "github.com/mattn/go-sqlite3"
9
-
"github.com/stretchr/testify/assert"
10
-
"tangled.org/core/rbac2"
11
-
)
12
-
13
-
func setup(t *testing.T) *rbac2.Enforcer {
14
-
enforcer, err := rbac2.NewEnforcer(":memory:")
15
-
assert.NoError(t, err)
16
-
17
-
return enforcer
18
-
}
19
-
20
-
func TestNewEnforcer(t *testing.T) {
21
-
db, err := sql.Open("sqlite3", "/tmp/test/test.db?_foreign_keys=1")
22
-
assert.NoError(t, err)
23
-
24
-
enforcer1, err := rbac2.NewEnforcerWithDB(db)
25
-
assert.NoError(t, err)
26
-
enforcer1.AddRepo(syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey"))
27
-
model1 := enforcer1.CaptureModel()
28
-
29
-
enforcer2, err := rbac2.NewEnforcerWithDB(db)
30
-
assert.NoError(t, err)
31
-
model2 := enforcer2.CaptureModel()
32
-
33
-
// model1.GetLogger().EnableLog(true)
34
-
// model1.PrintModel()
35
-
// model1.PrintPolicy()
36
-
// model1.GetLogger().EnableLog(false)
37
-
38
-
model2.GetLogger().EnableLog(true)
39
-
model2.PrintModel()
40
-
model2.PrintPolicy()
41
-
model2.GetLogger().EnableLog(false)
42
-
43
-
assert.Equal(t, model1, model2)
44
-
}
45
-
46
-
func TestRepoOwnerPermissions(t *testing.T) {
47
-
var (
48
-
e = setup(t)
49
-
ok bool
50
-
err error
51
-
fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey")
52
-
fooUser = syntax.DID("did:plc:foo")
53
-
)
54
-
55
-
assert.NoError(t, e.AddRepo(fooRepo))
56
-
57
-
ok, err = e.IsRepoOwner(fooUser, fooRepo)
58
-
assert.NoError(t, err)
59
-
assert.True(t, ok, "repo author should be repo owner")
60
-
61
-
ok, err = e.IsRepoWriteAllowed(fooUser, fooRepo)
62
-
assert.NoError(t, err)
63
-
assert.True(t, ok, "repo owner should be able to modify the repo itself")
64
-
65
-
ok, err = e.IsRepoCollaborator(fooUser, fooRepo)
66
-
assert.NoError(t, err)
67
-
assert.True(t, ok, "repo owner should inherit role role:collaborator")
68
-
69
-
ok, err = e.IsRepoSettingsWriteAllowed(fooUser, fooRepo)
70
-
assert.NoError(t, err)
71
-
assert.True(t, ok, "repo owner should inherit collaborator permissions")
72
-
}
73
-
74
-
func TestRepoCollaboratorPermissions(t *testing.T) {
75
-
var (
76
-
e = setup(t)
77
-
ok bool
78
-
err error
79
-
fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey")
80
-
barUser = syntax.DID("did:plc:bar")
81
-
)
82
-
83
-
assert.NoError(t, e.AddRepo(fooRepo))
84
-
assert.NoError(t, e.AddRepoCollaborator(barUser, fooRepo))
85
-
86
-
ok, err = e.IsRepoCollaborator(barUser, fooRepo)
87
-
assert.NoError(t, err)
88
-
assert.True(t, ok, "should set repo collaborator")
89
-
90
-
ok, err = e.IsRepoSettingsWriteAllowed(barUser, fooRepo)
91
-
assert.NoError(t, err)
92
-
assert.True(t, ok, "repo collaborator should be able to edit repo settings")
93
-
94
-
ok, err = e.IsRepoWriteAllowed(barUser, fooRepo)
95
-
assert.NoError(t, err)
96
-
assert.False(t, ok, "repo collaborator shouldn't be able to modify the repo itself")
97
-
}
98
-
99
-
func TestGetByRole(t *testing.T) {
100
-
var (
101
-
e = setup(t)
102
-
err error
103
-
fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey")
104
-
owner = syntax.DID("did:plc:foo")
105
-
collaborator1 = syntax.DID("did:plc:bar")
106
-
collaborator2 = syntax.DID("did:plc:baz")
107
-
)
108
-
109
-
assert.NoError(t, e.AddRepo(fooRepo))
110
-
assert.NoError(t, e.AddRepoCollaborator(collaborator1, fooRepo))
111
-
assert.NoError(t, e.AddRepoCollaborator(collaborator2, fooRepo))
112
-
113
-
collaborators, err := e.GetRepoCollaborators(fooRepo)
114
-
assert.NoError(t, err)
115
-
assert.ElementsMatch(t, []syntax.DID{
116
-
owner,
117
-
collaborator1,
118
-
collaborator2,
119
-
}, collaborators)
120
-
}
121
-
122
-
func TestSpindleOwnerPermissions(t *testing.T) {
123
-
var (
124
-
e = setup(t)
125
-
ok bool
126
-
err error
127
-
spindle = syntax.DID("did:web:spindle.example.com")
128
-
owner = syntax.DID("did:plc:foo")
129
-
member = syntax.DID("did:plc:bar")
130
-
)
131
-
132
-
assert.NoError(t, e.SetSpindleOwner(owner, spindle))
133
-
assert.NoError(t, e.AddSpindleMember(member, spindle))
134
-
135
-
ok, err = e.IsSpindleMember(owner, spindle)
136
-
assert.NoError(t, err)
137
-
assert.True(t, ok, "spindle owner is spindle member")
138
-
139
-
ok, err = e.IsSpindleMember(member, spindle)
140
-
assert.NoError(t, err)
141
-
assert.True(t, ok, "spindle member is spindle member")
142
-
143
-
ok, err = e.IsSpindleMemberInviteAllowed(owner, spindle)
144
-
assert.NoError(t, err)
145
-
assert.True(t, ok, "spindle owner can invite members")
146
-
147
-
ok, err = e.IsSpindleMemberInviteAllowed(member, spindle)
148
-
assert.NoError(t, err)
149
-
assert.False(t, ok, "spindle member cannot invite members")
150
-
}
···
-91
rbac2/repo.go
-91
rbac2/repo.go
···
1
-
package rbac2
2
-
3
-
import (
4
-
"slices"
5
-
"strings"
6
-
7
-
"github.com/bluesky-social/indigo/atproto/syntax"
8
-
"tangled.org/core/api/tangled"
9
-
)
10
-
11
-
// AddRepo adds new repo with its owner to rbac enforcer
12
-
func (e *Enforcer) AddRepo(repo syntax.ATURI) error {
13
-
if err := validateAtUri(repo, tangled.RepoNSID); err != nil {
14
-
return err
15
-
}
16
-
user := repo.Authority()
17
-
18
-
return e.setRoleForUser(user.String(), "repo:owner", repo.String())
19
-
}
20
-
21
-
// DeleteRepo deletes all policies related to the repo
22
-
func (e *Enforcer) DeleteRepo(repo syntax.ATURI) error {
23
-
if err := validateAtUri(repo, tangled.RepoNSID); err != nil {
24
-
return err
25
-
}
26
-
27
-
_, err := e.e.DeleteDomains(repo.String())
28
-
return err
29
-
}
30
-
31
-
// AddRepoCollaborator adds new collaborator to the repo
32
-
func (e *Enforcer) AddRepoCollaborator(user syntax.DID, repo syntax.ATURI) error {
33
-
if err := validateAtUri(repo, tangled.RepoNSID); err != nil {
34
-
return err
35
-
}
36
-
37
-
_, err := e.e.AddRoleForUser(user.String(), "repo:collaborator", repo.String())
38
-
return err
39
-
}
40
-
41
-
// RemoveRepoCollaborator removes the collaborator from the repo.
42
-
// This won't remove inherited roles like repository owner.
43
-
func (e *Enforcer) RemoveRepoCollaborator(user syntax.DID, repo syntax.ATURI) error {
44
-
if err := validateAtUri(repo, tangled.RepoNSID); err != nil {
45
-
return err
46
-
}
47
-
48
-
_, err := e.e.DeleteRoleForUser(user.String(), "repo:collaborator", repo.String())
49
-
return err
50
-
}
51
-
52
-
func (e *Enforcer) GetRepoCollaborators(repo syntax.ATURI) ([]syntax.DID, error) {
53
-
var collaborators []syntax.DID
54
-
members, err := e.e.GetImplicitUsersForRole("repo:collaborator", repo.String())
55
-
if err != nil {
56
-
return nil, err
57
-
}
58
-
for _, m := range members {
59
-
if !strings.HasPrefix(m, "did:") { // skip non-user subjects like 'repo:owner'
60
-
continue
61
-
}
62
-
collaborators = append(collaborators, syntax.DID(m))
63
-
}
64
-
65
-
slices.Sort(collaborators)
66
-
return slices.Compact(collaborators), nil
67
-
}
68
-
69
-
func (e *Enforcer) IsRepoOwner(user syntax.DID, repo syntax.ATURI) (bool, error) {
70
-
return e.e.HasRoleForUser(user.String(), "repo:owner", repo.String())
71
-
}
72
-
73
-
func (e *Enforcer) IsRepoCollaborator(user syntax.DID, repo syntax.ATURI) (bool, error) {
74
-
return e.hasImplicitRoleForUser(user.String(), "repo:collaborator", repo.String())
75
-
}
76
-
77
-
func (e *Enforcer) IsRepoWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) {
78
-
return e.e.Enforce(user.String(), repo.String(), "/", "write")
79
-
}
80
-
81
-
func (e *Enforcer) IsRepoSettingsWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) {
82
-
return e.e.Enforce(user.String(), repo.String(), "/settings", "write")
83
-
}
84
-
85
-
func (e *Enforcer) IsRepoCollaboratorInviteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) {
86
-
return e.e.Enforce(user.String(), repo.String(), "/collaborator", "write")
87
-
}
88
-
89
-
func (e *Enforcer) IsRepoGitPushAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) {
90
-
return e.e.Enforce(user.String(), repo.String(), "/git", "write")
91
-
}
···
-29
rbac2/spindle.go
-29
rbac2/spindle.go
···
1
-
package rbac2
2
-
3
-
import "github.com/bluesky-social/indigo/atproto/syntax"
4
-
5
-
func (e *Enforcer) SetSpindleOwner(user syntax.DID, spindle syntax.DID) error {
6
-
return e.setRoleForUser(user.String(), "server:owner", intoSpindle(spindle))
7
-
}
8
-
9
-
func (e *Enforcer) IsSpindleMember(user syntax.DID, spindle syntax.DID) (bool, error) {
10
-
return e.hasImplicitRoleForUser(user.String(), "server:member", intoSpindle(spindle))
11
-
}
12
-
13
-
func (e *Enforcer) AddSpindleMember(user syntax.DID, spindle syntax.DID) error {
14
-
_, err := e.e.AddRoleForUser(user.String(), "server:member", intoSpindle(spindle))
15
-
return err
16
-
}
17
-
18
-
func (e *Enforcer) RemoveSpindleMember(user syntax.DID, spindle syntax.DID) error {
19
-
_, err := e.e.DeleteRoleForUser(user.String(), "server:member", intoSpindle(spindle))
20
-
return err
21
-
}
22
-
23
-
func (e *Enforcer) IsSpindleMemberInviteAllowed(user syntax.DID, spindle syntax.DID) (bool, error) {
24
-
return e.e.Enforce(user.String(), intoSpindle(spindle), "/member", "write")
25
-
}
26
-
27
-
func intoSpindle(did syntax.DID) string {
28
-
return "/spindle/" + did.String()
29
-
}
···
-19
rbac2/tangled_policy.csv
-19
rbac2/tangled_policy.csv
···
1
-
#, policies
2
-
#, sub, dom, obj, act
3
-
p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /, write
4
-
p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /collaborator, write
5
-
p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /settings, write
6
-
p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /git, write
7
-
8
-
p, server:owner, /knot/{did}, /member, write
9
-
p, server:member, /knot/{did}, /git, write
10
-
11
-
p, server:owner, /spindle/{did}, /member, write
12
-
13
-
14
-
#, group policies
15
-
#, sub, role, dom
16
-
g, repo:owner, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}
17
-
18
-
g, server:owner, server:member, /knot/{did}
19
-
g, server:owner, server:member, /spindle/{did}
···
+11
-16
spindle/config/config.go
+11
-16
spindle/config/config.go
···
3
import (
4
"context"
5
"fmt"
6
-
"path/filepath"
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
"github.com/sethvargo/go-envconfig"
10
)
11
12
type Server struct {
13
-
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"`
14
-
Hostname string `env:"HOSTNAME, required"`
15
-
TapUrl string `env:"TAP_URL, required"`
16
-
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
17
-
Dev bool `env:"DEV, default=false"`
18
-
Owner syntax.DID `env:"OWNER, required"`
19
-
Secrets Secrets `env:",prefix=SECRETS_"`
20
-
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
21
-
DataDir string `env:"DATA_DIR, default=/var/lib/spindle"`
22
-
QueueSize int `env:"QUEUE_SIZE, default=100"`
23
-
MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time
24
}
25
26
func (s Server) Did() syntax.DID {
27
return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname))
28
-
}
29
-
30
-
func (s Server) DBPath() string {
31
-
return filepath.Join(s.DataDir, "spindle.db")
32
}
33
34
type Secrets struct {
···
3
import (
4
"context"
5
"fmt"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
"github.com/sethvargo/go-envconfig"
9
)
10
11
type Server struct {
12
+
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"`
13
+
DBPath string `env:"DB_PATH, default=spindle.db"`
14
+
Hostname string `env:"HOSTNAME, required"`
15
+
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
16
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
17
+
Dev bool `env:"DEV, default=false"`
18
+
Owner string `env:"OWNER, required"`
19
+
Secrets Secrets `env:",prefix=SECRETS_"`
20
+
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
21
+
QueueSize int `env:"QUEUE_SIZE, default=100"`
22
+
MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time
23
}
24
25
func (s Server) Did() syntax.DID {
26
return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname))
27
}
28
29
type Secrets struct {
+18
-73
spindle/db/db.go
+18
-73
spindle/db/db.go
···
1
package db
2
3
import (
4
-
"context"
5
"database/sql"
6
"strings"
7
8
-
"github.com/bluesky-social/indigo/atproto/syntax"
9
_ "github.com/mattn/go-sqlite3"
10
-
"tangled.org/core/log"
11
-
"tangled.org/core/orm"
12
)
13
14
type DB struct {
15
*sql.DB
16
}
17
18
-
func Make(ctx context.Context, dbPath string) (*DB, error) {
19
// https://github.com/mattn/go-sqlite3#connection-string
20
opts := []string{
21
"_foreign_keys=1",
···
24
"_auto_vacuum=incremental",
25
}
26
27
-
logger := log.FromContext(ctx)
28
-
logger = log.SubLogger(logger, "db")
29
-
30
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
31
if err != nil {
32
return nil, err
33
}
34
35
-
conn, err := db.Conn(ctx)
36
-
if err != nil {
37
-
return nil, err
38
-
}
39
-
defer conn.Close()
40
41
_, err = db.Exec(`
42
create table if not exists _jetstream (
···
58
unique(owner, name)
59
);
60
61
-
create table if not exists repo_collaborators (
62
-
-- identifiers
63
-
id integer primary key autoincrement,
64
-
did text not null,
65
-
rkey text not null,
66
-
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.collaborator' || '/' || rkey) stored,
67
-
68
-
repo text not null,
69
-
subject text not null,
70
-
71
-
addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
72
-
unique(did, rkey)
73
-
);
74
-
75
create table if not exists spindle_members (
76
-- identifiers for the record
77
id integer primary key autoincrement,
···
99
return nil, err
100
}
101
102
-
// run migrations
103
104
-
// NOTE: this won't migrate existing records
105
-
// they will be fetched again with tap instead
106
-
orm.RunMigration(conn, logger, "add-rkey-to-repos", func(tx *sql.Tx) error {
107
-
// archive legacy repos (just in case)
108
-
_, err = tx.Exec(`alter table repos rename to repos_old`)
109
-
if err != nil {
110
-
return err
111
-
}
112
-
113
-
_, err := tx.Exec(`
114
-
create table repos (
115
-
-- identifiers
116
-
id integer primary key autoincrement,
117
-
did text not null,
118
-
rkey text not null,
119
-
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo' || '/' || rkey) stored,
120
-
121
-
name text not null,
122
-
knot text not null,
123
-
124
-
addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
125
-
unique(did, rkey)
126
-
);
127
-
`)
128
-
if err != nil {
129
-
return err
130
-
}
131
-
132
-
return nil
133
-
})
134
-
135
-
return &DB{db}, nil
136
}
137
138
-
func (d *DB) IsKnownDid(did syntax.DID) (bool, error) {
139
-
// is spindle member / repo collaborator
140
-
var exists bool
141
-
err := d.QueryRow(
142
-
`select exists (
143
-
select 1 from repo_collaborators where subject = ?
144
-
union all
145
-
select 1 from spindle_members where did = ?
146
-
)`,
147
-
did,
148
-
did,
149
-
).Scan(&exists)
150
-
return exists, err
151
}
···
1
package db
2
3
import (
4
"database/sql"
5
"strings"
6
7
_ "github.com/mattn/go-sqlite3"
8
)
9
10
type DB struct {
11
*sql.DB
12
}
13
14
+
func Make(dbPath string) (*DB, error) {
15
// https://github.com/mattn/go-sqlite3#connection-string
16
opts := []string{
17
"_foreign_keys=1",
···
20
"_auto_vacuum=incremental",
21
}
22
23
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
24
if err != nil {
25
return nil, err
26
}
27
28
+
// NOTE: If any other migration is added here, you MUST
29
+
// copy the pattern in appview: use a single sql.Conn
30
+
// for every migration.
31
32
_, err = db.Exec(`
33
create table if not exists _jetstream (
···
49
unique(owner, name)
50
);
51
52
create table if not exists spindle_members (
53
-- identifiers for the record
54
id integer primary key autoincrement,
···
76
return nil, err
77
}
78
79
+
return &DB{db}, nil
80
+
}
81
82
+
func (d *DB) SaveLastTimeUs(lastTimeUs int64) error {
83
+
_, err := d.Exec(`
84
+
insert into _jetstream (id, last_time_us)
85
+
values (1, ?)
86
+
on conflict(id) do update set last_time_us = excluded.last_time_us
87
+
`, lastTimeUs)
88
+
return err
89
}
90
91
+
func (d *DB) GetLastTimeUs() (int64, error) {
92
+
var lastTimeUs int64
93
+
row := d.QueryRow(`select last_time_us from _jetstream where id = 1;`)
94
+
err := row.Scan(&lastTimeUs)
95
+
return lastTimeUs, err
96
}
+18
-6
spindle/db/events.go
+18
-6
spindle/db/events.go
···
18
EventJson string `json:"event"`
19
}
20
21
-
func (d *DB) insertEvent(event Event, notifier *notifier.Notifier) error {
22
_, err := d.Exec(
23
`insert into events (rkey, nsid, event, created) values (?, ?, ?, ?)`,
24
event.Rkey,
···
70
return evts, nil
71
}
72
73
func (d *DB) createStatusEvent(
74
workflowId models.WorkflowId,
75
statusKind models.StatusKind,
···
100
EventJson: string(eventJson),
101
}
102
103
-
return d.insertEvent(event, n)
104
105
}
106
···
148
149
func (d *DB) StatusFailed(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error {
150
return d.createStatusEvent(workflowId, models.StatusKindFailed, &workflowError, &exitCode, n)
151
-
}
152
-
153
-
func (d *DB) StatusCancelled(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error {
154
-
return d.createStatusEvent(workflowId, models.StatusKindCancelled, &workflowError, &exitCode, n)
155
}
156
157
func (d *DB) StatusSuccess(workflowId models.WorkflowId, n *notifier.Notifier) error {
···
18
EventJson string `json:"event"`
19
}
20
21
+
func (d *DB) InsertEvent(event Event, notifier *notifier.Notifier) error {
22
_, err := d.Exec(
23
`insert into events (rkey, nsid, event, created) values (?, ?, ?, ?)`,
24
event.Rkey,
···
70
return evts, nil
71
}
72
73
+
func (d *DB) CreateStatusEvent(rkey string, s tangled.PipelineStatus, n *notifier.Notifier) error {
74
+
eventJson, err := json.Marshal(s)
75
+
if err != nil {
76
+
return err
77
+
}
78
+
79
+
event := Event{
80
+
Rkey: rkey,
81
+
Nsid: tangled.PipelineStatusNSID,
82
+
Created: time.Now().UnixNano(),
83
+
EventJson: string(eventJson),
84
+
}
85
+
86
+
return d.InsertEvent(event, n)
87
+
}
88
+
89
func (d *DB) createStatusEvent(
90
workflowId models.WorkflowId,
91
statusKind models.StatusKind,
···
116
EventJson: string(eventJson),
117
}
118
119
+
return d.InsertEvent(event, n)
120
121
}
122
···
164
165
func (d *DB) StatusFailed(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error {
166
return d.createStatusEvent(workflowId, models.StatusKindFailed, &workflowError, &exitCode, n)
167
}
168
169
func (d *DB) StatusSuccess(workflowId models.WorkflowId, n *notifier.Notifier) error {
+44
spindle/db/known_dids.go
+44
spindle/db/known_dids.go
···
···
1
+
package db
2
+
3
+
func (d *DB) AddDid(did string) error {
4
+
_, err := d.Exec(`insert or ignore into known_dids (did) values (?)`, did)
5
+
return err
6
+
}
7
+
8
+
func (d *DB) RemoveDid(did string) error {
9
+
_, err := d.Exec(`delete from known_dids where did = ?`, did)
10
+
return err
11
+
}
12
+
13
+
func (d *DB) GetAllDids() ([]string, error) {
14
+
var dids []string
15
+
16
+
rows, err := d.Query(`select did from known_dids`)
17
+
if err != nil {
18
+
return nil, err
19
+
}
20
+
defer rows.Close()
21
+
22
+
for rows.Next() {
23
+
var did string
24
+
if err := rows.Scan(&did); err != nil {
25
+
return nil, err
26
+
}
27
+
dids = append(dids, did)
28
+
}
29
+
30
+
if err := rows.Err(); err != nil {
31
+
return nil, err
32
+
}
33
+
34
+
return dids, nil
35
+
}
36
+
37
+
func (d *DB) HasKnownDids() bool {
38
+
var count int
39
+
err := d.QueryRow(`select count(*) from known_dids`).Scan(&count)
40
+
if err != nil {
41
+
return false
42
+
}
43
+
return count > 0
44
+
}
+11
-119
spindle/db/repos.go
+11
-119
spindle/db/repos.go
···
1
package db
2
3
-
import "github.com/bluesky-social/indigo/atproto/syntax"
4
-
5
type Repo struct {
6
-
Did syntax.DID
7
-
Rkey syntax.RecordKey
8
-
Name string
9
-
Knot string
10
}
11
12
-
type RepoCollaborator struct {
13
-
Did syntax.DID
14
-
Rkey syntax.RecordKey
15
-
Repo syntax.ATURI
16
-
Subject syntax.DID
17
-
}
18
-
19
-
func (d *DB) PutRepo(repo *Repo) error {
20
-
_, err := d.Exec(
21
-
`insert or ignore into repos (did, rkey, name, knot)
22
-
values (?, ?, ?, ?)
23
-
on conflict(did, rkey) do update set
24
-
name = excluded.name,
25
-
knot = excluded.knot`,
26
-
repo.Did,
27
-
repo.Rkey,
28
-
repo.Name,
29
-
repo.Knot,
30
-
)
31
-
return err
32
-
}
33
-
34
-
func (d *DB) DeleteRepo(did syntax.DID, rkey syntax.RecordKey) error {
35
-
_, err := d.Exec(
36
-
`delete from repos where did = ? and rkey = ?`,
37
-
did,
38
-
rkey,
39
-
)
40
return err
41
}
42
···
63
return knots, nil
64
}
65
66
-
func (d *DB) GetRepo(repoAt syntax.ATURI) (*Repo, error) {
67
var repo Repo
68
-
err := d.DB.QueryRow(
69
-
`select
70
-
did,
71
-
rkey,
72
-
name,
73
-
knot
74
-
from repos where at_uri = ?`,
75
-
repoAt,
76
-
).Scan(
77
-
&repo.Did,
78
-
&repo.Rkey,
79
-
&repo.Name,
80
-
&repo.Knot,
81
-
)
82
-
if err != nil {
83
-
return nil, err
84
-
}
85
-
return &repo, nil
86
-
}
87
88
-
func (d *DB) GetRepoWithName(did syntax.DID, name string) (*Repo, error) {
89
-
var repo Repo
90
-
err := d.DB.QueryRow(
91
-
`select
92
-
did,
93
-
rkey,
94
-
name,
95
-
knot
96
-
from repos where did = ? and name = ?`,
97
-
did,
98
-
name,
99
-
).Scan(
100
-
&repo.Did,
101
-
&repo.Rkey,
102
-
&repo.Name,
103
-
&repo.Knot,
104
-
)
105
if err != nil {
106
return nil, err
107
}
108
return &repo, nil
109
}
110
-
111
-
func (d *DB) PutRepoCollaborator(collaborator *RepoCollaborator) error {
112
-
_, err := d.Exec(
113
-
`insert into repo_collaborators (did, rkey, repo, subject)
114
-
values (?, ?, ?, ?)
115
-
on conflict(did, rkey) do update set
116
-
repo = excluded.repo,
117
-
subject = excluded.subject`,
118
-
collaborator.Did,
119
-
collaborator.Rkey,
120
-
collaborator.Repo,
121
-
collaborator.Subject,
122
-
)
123
-
return err
124
-
}
125
-
126
-
func (d *DB) RemoveRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) error {
127
-
_, err := d.Exec(
128
-
`delete from repo_collaborators where did = ? and rkey = ?`,
129
-
did,
130
-
rkey,
131
-
)
132
-
return err
133
-
}
134
-
135
-
func (d *DB) GetRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) (*RepoCollaborator, error) {
136
-
var collaborator RepoCollaborator
137
-
err := d.DB.QueryRow(
138
-
`select
139
-
did,
140
-
rkey,
141
-
repo,
142
-
subject
143
-
from repo_collaborators
144
-
where did = ? and rkey = ?`,
145
-
did,
146
-
rkey,
147
-
).Scan(
148
-
&collaborator.Did,
149
-
&collaborator.Rkey,
150
-
&collaborator.Repo,
151
-
&collaborator.Subject,
152
-
)
153
-
if err != nil {
154
-
return nil, err
155
-
}
156
-
return &collaborator, nil
157
-
}
···
1
package db
2
3
type Repo struct {
4
+
Knot string
5
+
Owner string
6
+
Name string
7
}
8
9
+
func (d *DB) AddRepo(knot, owner, name string) error {
10
+
_, err := d.Exec(`insert or ignore into repos (knot, owner, name) values (?, ?, ?)`, knot, owner, name)
11
return err
12
}
13
···
34
return knots, nil
35
}
36
37
+
func (d *DB) GetRepo(knot, owner, name string) (*Repo, error) {
38
var repo Repo
39
40
+
query := "select knot, owner, name from repos where knot = ? and owner = ? and name = ?"
41
+
err := d.DB.QueryRow(query, knot, owner, name).
42
+
Scan(&repo.Knot, &repo.Owner, &repo.Name)
43
+
44
if err != nil {
45
return nil, err
46
}
47
+
48
return &repo, nil
49
}
+13
-24
spindle/engines/nixery/engine.go
+13
-24
spindle/engines/nixery/engine.go
···
179
return err
180
}
181
e.registerCleanup(wid, func(ctx context.Context) error {
182
-
if err := e.docker.NetworkRemove(ctx, networkName(wid)); err != nil {
183
-
return fmt.Errorf("removing network: %w", err)
184
-
}
185
-
return nil
186
})
187
188
addl := wf.Data.(addlFields)
···
232
return fmt.Errorf("creating container: %w", err)
233
}
234
e.registerCleanup(wid, func(ctx context.Context) error {
235
-
if err := e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}); err != nil {
236
-
return fmt.Errorf("stopping container: %w", err)
237
}
238
239
-
err := e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{
240
RemoveVolumes: true,
241
RemoveLinks: false,
242
Force: false,
243
})
244
-
if err != nil {
245
-
return fmt.Errorf("removing container: %w", err)
246
-
}
247
-
return nil
248
})
249
250
-
if err := e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
251
return fmt.Errorf("starting container: %w", err)
252
}
253
···
399
}
400
401
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
402
-
fns := e.drainCleanups(wid)
403
404
for _, fn := range fns {
405
if err := fn(ctx); err != nil {
···
415
416
key := wid.String()
417
e.cleanup[key] = append(e.cleanup[key], fn)
418
-
}
419
-
420
-
func (e *Engine) drainCleanups(wid models.WorkflowId) []cleanupFunc {
421
-
e.cleanupMu.Lock()
422
-
key := wid.String()
423
-
424
-
fns := e.cleanup[key]
425
-
delete(e.cleanup, key)
426
-
e.cleanupMu.Unlock()
427
-
428
-
return fns
429
}
430
431
func networkName(wid models.WorkflowId) string {
···
179
return err
180
}
181
e.registerCleanup(wid, func(ctx context.Context) error {
182
+
return e.docker.NetworkRemove(ctx, networkName(wid))
183
})
184
185
addl := wf.Data.(addlFields)
···
229
return fmt.Errorf("creating container: %w", err)
230
}
231
e.registerCleanup(wid, func(ctx context.Context) error {
232
+
err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{})
233
+
if err != nil {
234
+
return err
235
}
236
237
+
return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{
238
RemoveVolumes: true,
239
RemoveLinks: false,
240
Force: false,
241
})
242
})
243
244
+
err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{})
245
+
if err != nil {
246
return fmt.Errorf("starting container: %w", err)
247
}
248
···
394
}
395
396
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
397
+
e.cleanupMu.Lock()
398
+
key := wid.String()
399
+
400
+
fns := e.cleanup[key]
401
+
delete(e.cleanup, key)
402
+
e.cleanupMu.Unlock()
403
404
for _, fn := range fns {
405
if err := fn(ctx); err != nil {
···
415
416
key := wid.String()
417
e.cleanup[key] = append(e.cleanup[key], fn)
418
}
419
420
func networkName(wid models.WorkflowId) string {
+300
spindle/ingester.go
+300
spindle/ingester.go
···
···
1
+
package spindle
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"errors"
7
+
"fmt"
8
+
"time"
9
+
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/eventconsumer"
12
+
"tangled.org/core/rbac"
13
+
"tangled.org/core/spindle/db"
14
+
15
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
16
+
"github.com/bluesky-social/indigo/atproto/identity"
17
+
"github.com/bluesky-social/indigo/atproto/syntax"
18
+
"github.com/bluesky-social/indigo/xrpc"
19
+
"github.com/bluesky-social/jetstream/pkg/models"
20
+
securejoin "github.com/cyphar/filepath-securejoin"
21
+
)
22
+
23
+
type Ingester func(ctx context.Context, e *models.Event) error
24
+
25
+
func (s *Spindle) ingest() Ingester {
26
+
return func(ctx context.Context, e *models.Event) error {
27
+
var err error
28
+
defer func() {
29
+
eventTime := e.TimeUS
30
+
lastTimeUs := eventTime + 1
31
+
if err := s.db.SaveLastTimeUs(lastTimeUs); err != nil {
32
+
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
33
+
}
34
+
}()
35
+
36
+
if e.Kind != models.EventKindCommit {
37
+
return nil
38
+
}
39
+
40
+
switch e.Commit.Collection {
41
+
case tangled.SpindleMemberNSID:
42
+
err = s.ingestMember(ctx, e)
43
+
case tangled.RepoNSID:
44
+
err = s.ingestRepo(ctx, e)
45
+
case tangled.RepoCollaboratorNSID:
46
+
err = s.ingestCollaborator(ctx, e)
47
+
}
48
+
49
+
if err != nil {
50
+
s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err)
51
+
}
52
+
53
+
return nil
54
+
}
55
+
}
56
+
57
+
func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error {
58
+
var err error
59
+
did := e.Did
60
+
rkey := e.Commit.RKey
61
+
62
+
l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID)
63
+
64
+
switch e.Commit.Operation {
65
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
66
+
raw := e.Commit.Record
67
+
record := tangled.SpindleMember{}
68
+
err = json.Unmarshal(raw, &record)
69
+
if err != nil {
70
+
l.Error("invalid record", "error", err)
71
+
return err
72
+
}
73
+
74
+
domain := s.cfg.Server.Hostname
75
+
recordInstance := record.Instance
76
+
77
+
if recordInstance != domain {
78
+
l.Error("domain mismatch", "domain", recordInstance, "expected", domain)
79
+
return fmt.Errorf("domain mismatch: %s != %s", record.Instance, domain)
80
+
}
81
+
82
+
ok, err := s.e.IsSpindleInviteAllowed(did, rbacDomain)
83
+
if err != nil || !ok {
84
+
l.Error("failed to add member", "did", did, "error", err)
85
+
return fmt.Errorf("failed to enforce permissions: %w", err)
86
+
}
87
+
88
+
if err := db.AddSpindleMember(s.db, db.SpindleMember{
89
+
Did: syntax.DID(did),
90
+
Rkey: rkey,
91
+
Instance: recordInstance,
92
+
Subject: syntax.DID(record.Subject),
93
+
Created: time.Now(),
94
+
}); err != nil {
95
+
l.Error("failed to add member", "error", err)
96
+
return fmt.Errorf("failed to add member: %w", err)
97
+
}
98
+
99
+
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
100
+
l.Error("failed to add member", "error", err)
101
+
return fmt.Errorf("failed to add member: %w", err)
102
+
}
103
+
l.Info("added member from firehose", "member", record.Subject)
104
+
105
+
if err := s.db.AddDid(record.Subject); err != nil {
106
+
l.Error("failed to add did", "error", err)
107
+
return fmt.Errorf("failed to add did: %w", err)
108
+
}
109
+
s.jc.AddDid(record.Subject)
110
+
111
+
return nil
112
+
113
+
case models.CommitOperationDelete:
114
+
record, err := db.GetSpindleMember(s.db, did, rkey)
115
+
if err != nil {
116
+
l.Error("failed to find member", "error", err)
117
+
return fmt.Errorf("failed to find member: %w", err)
118
+
}
119
+
120
+
if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil {
121
+
l.Error("failed to remove member", "error", err)
122
+
return fmt.Errorf("failed to remove member: %w", err)
123
+
}
124
+
125
+
if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil {
126
+
l.Error("failed to add member", "error", err)
127
+
return fmt.Errorf("failed to add member: %w", err)
128
+
}
129
+
l.Info("added member from firehose", "member", record.Subject)
130
+
131
+
if err := s.db.RemoveDid(record.Subject.String()); err != nil {
132
+
l.Error("failed to add did", "error", err)
133
+
return fmt.Errorf("failed to add did: %w", err)
134
+
}
135
+
s.jc.RemoveDid(record.Subject.String())
136
+
137
+
}
138
+
return nil
139
+
}
140
+
141
+
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
142
+
var err error
143
+
did := e.Did
144
+
145
+
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
146
+
147
+
l.Info("ingesting repo record", "did", did)
148
+
149
+
switch e.Commit.Operation {
150
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
151
+
raw := e.Commit.Record
152
+
record := tangled.Repo{}
153
+
err = json.Unmarshal(raw, &record)
154
+
if err != nil {
155
+
l.Error("invalid record", "error", err)
156
+
return err
157
+
}
158
+
159
+
domain := s.cfg.Server.Hostname
160
+
161
+
// no spindle configured for this repo
162
+
if record.Spindle == nil {
163
+
l.Info("no spindle configured", "name", record.Name)
164
+
return nil
165
+
}
166
+
167
+
// this repo did not want this spindle
168
+
if *record.Spindle != domain {
169
+
l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain)
170
+
return nil
171
+
}
172
+
173
+
// add this repo to the watch list
174
+
if err := s.db.AddRepo(record.Knot, did, record.Name); err != nil {
175
+
l.Error("failed to add repo", "error", err)
176
+
return fmt.Errorf("failed to add repo: %w", err)
177
+
}
178
+
179
+
didSlashRepo, err := securejoin.SecureJoin(did, record.Name)
180
+
if err != nil {
181
+
return err
182
+
}
183
+
184
+
// add repo to rbac
185
+
if err := s.e.AddRepo(did, rbac.ThisServer, didSlashRepo); err != nil {
186
+
l.Error("failed to add repo to enforcer", "error", err)
187
+
return fmt.Errorf("failed to add repo: %w", err)
188
+
}
189
+
190
+
// add collaborators to rbac
191
+
owner, err := s.res.ResolveIdent(ctx, did)
192
+
if err != nil || owner.Handle.IsInvalidHandle() {
193
+
return err
194
+
}
195
+
if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil {
196
+
return err
197
+
}
198
+
199
+
// add this knot to the event consumer
200
+
src := eventconsumer.NewKnotSource(record.Knot)
201
+
s.ks.AddSource(context.Background(), src)
202
+
203
+
return nil
204
+
205
+
}
206
+
return nil
207
+
}
208
+
209
+
func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error {
210
+
var err error
211
+
212
+
l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did)
213
+
214
+
l.Info("ingesting collaborator record")
215
+
216
+
switch e.Commit.Operation {
217
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
218
+
raw := e.Commit.Record
219
+
record := tangled.RepoCollaborator{}
220
+
err = json.Unmarshal(raw, &record)
221
+
if err != nil {
222
+
l.Error("invalid record", "error", err)
223
+
return err
224
+
}
225
+
226
+
subjectId, err := s.res.ResolveIdent(ctx, record.Subject)
227
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
228
+
return err
229
+
}
230
+
231
+
repoAt, err := syntax.ParseATURI(record.Repo)
232
+
if err != nil {
233
+
l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo)
234
+
return nil
235
+
}
236
+
237
+
// TODO: get rid of this entirely
238
+
// resolve this aturi to extract the repo record
239
+
owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String())
240
+
if err != nil || owner.Handle.IsInvalidHandle() {
241
+
return fmt.Errorf("failed to resolve handle: %w", err)
242
+
}
243
+
244
+
xrpcc := xrpc.Client{
245
+
Host: owner.PDSEndpoint(),
246
+
}
247
+
248
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
249
+
if err != nil {
250
+
return err
251
+
}
252
+
253
+
repo := resp.Value.Val.(*tangled.Repo)
254
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
255
+
256
+
// check perms for this user
257
+
if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil {
258
+
return fmt.Errorf("insufficient permissions: %w", err)
259
+
}
260
+
261
+
// add collaborator to rbac
262
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
263
+
l.Error("failed to add repo to enforcer", "error", err)
264
+
return fmt.Errorf("failed to add repo: %w", err)
265
+
}
266
+
267
+
return nil
268
+
}
269
+
return nil
270
+
}
271
+
272
+
func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error {
273
+
l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators")
274
+
275
+
l.Info("fetching and adding existing collaborators")
276
+
277
+
xrpcc := xrpc.Client{
278
+
Host: owner.PDSEndpoint(),
279
+
}
280
+
281
+
resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false)
282
+
if err != nil {
283
+
return err
284
+
}
285
+
286
+
var errs error
287
+
for _, r := range resp.Records {
288
+
if r == nil {
289
+
continue
290
+
}
291
+
record := r.Value.Val.(*tangled.RepoCollaborator)
292
+
293
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
294
+
l.Error("failed to add repo to enforcer", "error", err)
295
+
errors.Join(errs, fmt.Errorf("failed to add repo: %w", err))
296
+
}
297
+
}
298
+
299
+
return errs
300
+
}
+1
-1
spindle/models/pipeline_env.go
+1
-1
spindle/models/pipeline_env.go
+110
-41
spindle/server.go
+110
-41
spindle/server.go
···
8
"log/slog"
9
"maps"
10
"net/http"
11
12
-
"github.com/bluesky-social/indigo/atproto/syntax"
13
"github.com/go-chi/chi/v5"
14
"tangled.org/core/api/tangled"
15
"tangled.org/core/eventconsumer"
16
"tangled.org/core/eventconsumer/cursor"
17
"tangled.org/core/idresolver"
18
"tangled.org/core/log"
19
"tangled.org/core/notifier"
20
-
"tangled.org/core/rbac2"
21
"tangled.org/core/spindle/config"
22
"tangled.org/core/spindle/db"
23
"tangled.org/core/spindle/engine"
···
26
"tangled.org/core/spindle/queue"
27
"tangled.org/core/spindle/secrets"
28
"tangled.org/core/spindle/xrpc"
29
-
"tangled.org/core/tap"
30
"tangled.org/core/xrpc/serviceauth"
31
)
32
33
//go:embed motd
34
-
var motd []byte
35
36
type Spindle struct {
37
-
tap *tap.Client
38
-
db *db.DB
39
-
e *rbac2.Enforcer
40
-
l *slog.Logger
41
-
n *notifier.Notifier
42
-
engs map[string]models.Engine
43
-
jq *queue.Queue
44
-
cfg *config.Config
45
-
ks *eventconsumer.Consumer
46
-
res *idresolver.Resolver
47
-
vault secrets.Manager
48
}
49
50
// New creates a new Spindle server with the provided configuration and engines.
51
func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) {
52
logger := log.FromContext(ctx)
53
54
-
d, err := db.Make(ctx, cfg.Server.DBPath)
55
if err != nil {
56
return nil, fmt.Errorf("failed to setup db: %w", err)
57
}
58
59
-
e, err := rbac2.NewEnforcer(cfg.Server.DBPath)
60
if err != nil {
61
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
62
}
63
64
n := notifier.New()
65
···
91
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
92
logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount)
93
94
-
tap := tap.NewClient(cfg.Server.TapUrl, "")
95
96
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
97
98
spindle := &Spindle{
99
-
tap: &tap,
100
e: e,
101
db: d,
102
l: logger,
···
106
cfg: cfg,
107
res: resolver,
108
vault: vault,
109
}
110
111
-
err = e.SetSpindleOwner(spindle.cfg.Server.Owner, spindle.cfg.Server.Did())
112
if err != nil {
113
return nil, err
114
}
···
117
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
118
if err != nil {
119
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
120
}
121
122
// for each incoming sh.tangled.pipeline, we execute
···
166
}
167
168
// Enforcer returns the RBAC enforcer instance.
169
-
func (s *Spindle) Enforcer() *rbac2.Enforcer {
170
return s.e
171
}
172
173
// Start starts the Spindle server (blocking).
···
186
s.ks.Start(ctx)
187
}()
188
189
-
// ensure server owner is tracked
190
-
if err := s.tap.AddRepos(ctx, []syntax.DID{s.cfg.Server.Owner}); err != nil {
191
-
return err
192
-
}
193
-
194
-
go func() {
195
-
s.l.Info("starting tap stream consumer")
196
-
s.tap.Connect(ctx, &tap.SimpleIndexer{
197
-
EventHandler: s.processEvent,
198
-
})
199
-
}()
200
-
201
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
202
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
203
}
···
227
mux := chi.NewRouter()
228
229
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
230
-
w.Write(motd)
231
})
232
mux.HandleFunc("/events", s.Events)
233
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
···
249
Config: s.cfg,
250
Resolver: s.res,
251
Vault: s.vault,
252
-
Notifier: s.Notifier(),
253
ServiceAuth: serviceAuth,
254
}
255
···
278
}
279
280
// filter by repos
281
-
_, err = s.db.GetRepoWithName(
282
-
syntax.DID(tpl.TriggerMetadata.Repo.Did),
283
tpl.TriggerMetadata.Repo.Repo,
284
)
285
if err != nil {
286
-
return fmt.Errorf("failed to get repo: %w", err)
287
}
288
289
pipelineId := models.PipelineId{
···
304
Name: w.Name,
305
}, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n)
306
if err != nil {
307
-
return fmt.Errorf("db.StatusFailed: %w", err)
308
}
309
310
continue
···
318
319
ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl)
320
if err != nil {
321
-
return fmt.Errorf("init workflow: %w", err)
322
}
323
324
// inject TANGLED_* env vars after InitWorkflow
···
335
Name: w.Name,
336
}, s.n)
337
if err != nil {
338
-
return fmt.Errorf("db.StatusPending: %w", err)
339
}
340
}
341
}
···
362
363
return nil
364
}
···
8
"log/slog"
9
"maps"
10
"net/http"
11
+
"sync"
12
13
"github.com/go-chi/chi/v5"
14
"tangled.org/core/api/tangled"
15
"tangled.org/core/eventconsumer"
16
"tangled.org/core/eventconsumer/cursor"
17
"tangled.org/core/idresolver"
18
+
"tangled.org/core/jetstream"
19
"tangled.org/core/log"
20
"tangled.org/core/notifier"
21
+
"tangled.org/core/rbac"
22
"tangled.org/core/spindle/config"
23
"tangled.org/core/spindle/db"
24
"tangled.org/core/spindle/engine"
···
27
"tangled.org/core/spindle/queue"
28
"tangled.org/core/spindle/secrets"
29
"tangled.org/core/spindle/xrpc"
30
"tangled.org/core/xrpc/serviceauth"
31
)
32
33
//go:embed motd
34
+
var defaultMotd []byte
35
+
36
+
const (
37
+
rbacDomain = "thisserver"
38
+
)
39
40
type Spindle struct {
41
+
jc *jetstream.JetstreamClient
42
+
db *db.DB
43
+
e *rbac.Enforcer
44
+
l *slog.Logger
45
+
n *notifier.Notifier
46
+
engs map[string]models.Engine
47
+
jq *queue.Queue
48
+
cfg *config.Config
49
+
ks *eventconsumer.Consumer
50
+
res *idresolver.Resolver
51
+
vault secrets.Manager
52
+
motd []byte
53
+
motdMu sync.RWMutex
54
}
55
56
// New creates a new Spindle server with the provided configuration and engines.
57
func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) {
58
logger := log.FromContext(ctx)
59
60
+
d, err := db.Make(cfg.Server.DBPath)
61
if err != nil {
62
return nil, fmt.Errorf("failed to setup db: %w", err)
63
}
64
65
+
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
66
if err != nil {
67
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
68
}
69
+
e.E.EnableAutoSave(true)
70
71
n := notifier.New()
72
···
98
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
99
logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount)
100
101
+
collections := []string{
102
+
tangled.SpindleMemberNSID,
103
+
tangled.RepoNSID,
104
+
tangled.RepoCollaboratorNSID,
105
+
}
106
+
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
107
+
if err != nil {
108
+
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
109
+
}
110
+
jc.AddDid(cfg.Server.Owner)
111
+
112
+
// Check if the spindle knows about any Dids;
113
+
dids, err := d.GetAllDids()
114
+
if err != nil {
115
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
116
+
}
117
+
for _, d := range dids {
118
+
jc.AddDid(d)
119
+
}
120
121
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
122
123
spindle := &Spindle{
124
+
jc: jc,
125
e: e,
126
db: d,
127
l: logger,
···
131
cfg: cfg,
132
res: resolver,
133
vault: vault,
134
+
motd: defaultMotd,
135
}
136
137
+
err = e.AddSpindle(rbacDomain)
138
+
if err != nil {
139
+
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
140
+
}
141
+
err = spindle.configureOwner()
142
if err != nil {
143
return nil, err
144
}
···
147
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
148
if err != nil {
149
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
150
+
}
151
+
152
+
err = jc.StartJetstream(ctx, spindle.ingest())
153
+
if err != nil {
154
+
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
155
}
156
157
// for each incoming sh.tangled.pipeline, we execute
···
201
}
202
203
// Enforcer returns the RBAC enforcer instance.
204
+
func (s *Spindle) Enforcer() *rbac.Enforcer {
205
return s.e
206
+
}
207
+
208
+
// SetMotdContent sets custom MOTD content, replacing the embedded default.
209
+
func (s *Spindle) SetMotdContent(content []byte) {
210
+
s.motdMu.Lock()
211
+
defer s.motdMu.Unlock()
212
+
s.motd = content
213
+
}
214
+
215
+
// GetMotdContent returns the current MOTD content.
216
+
func (s *Spindle) GetMotdContent() []byte {
217
+
s.motdMu.RLock()
218
+
defer s.motdMu.RUnlock()
219
+
return s.motd
220
}
221
222
// Start starts the Spindle server (blocking).
···
235
s.ks.Start(ctx)
236
}()
237
238
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
239
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
240
}
···
264
mux := chi.NewRouter()
265
266
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
267
+
w.Write(s.GetMotdContent())
268
})
269
mux.HandleFunc("/events", s.Events)
270
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
···
286
Config: s.cfg,
287
Resolver: s.res,
288
Vault: s.vault,
289
ServiceAuth: serviceAuth,
290
}
291
···
314
}
315
316
// filter by repos
317
+
_, err = s.db.GetRepo(
318
+
tpl.TriggerMetadata.Repo.Knot,
319
+
tpl.TriggerMetadata.Repo.Did,
320
tpl.TriggerMetadata.Repo.Repo,
321
)
322
if err != nil {
323
+
return err
324
}
325
326
pipelineId := models.PipelineId{
···
341
Name: w.Name,
342
}, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n)
343
if err != nil {
344
+
return err
345
}
346
347
continue
···
355
356
ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl)
357
if err != nil {
358
+
return err
359
}
360
361
// inject TANGLED_* env vars after InitWorkflow
···
372
Name: w.Name,
373
}, s.n)
374
if err != nil {
375
+
return err
376
}
377
}
378
}
···
399
400
return nil
401
}
402
+
403
+
func (s *Spindle) configureOwner() error {
404
+
cfgOwner := s.cfg.Server.Owner
405
+
406
+
existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain)
407
+
if err != nil {
408
+
return err
409
+
}
410
+
411
+
switch len(existing) {
412
+
case 0:
413
+
// no owner configured, continue
414
+
case 1:
415
+
// find existing owner
416
+
existingOwner := existing[0]
417
+
418
+
// no ownership change, this is okay
419
+
if existingOwner == s.cfg.Server.Owner {
420
+
break
421
+
}
422
+
423
+
// remove existing owner
424
+
err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner)
425
+
if err != nil {
426
+
return nil
427
+
}
428
+
default:
429
+
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath)
430
+
}
431
+
432
+
return s.e.AddSpindleOwner(rbacDomain, cfgOwner)
433
+
}
-294
spindle/tap.go
-294
spindle/tap.go
···
1
-
package spindle
2
-
3
-
import (
4
-
"context"
5
-
"encoding/json"
6
-
"fmt"
7
-
"time"
8
-
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"tangled.org/core/api/tangled"
11
-
"tangled.org/core/eventconsumer"
12
-
"tangled.org/core/spindle/db"
13
-
"tangled.org/core/tap"
14
-
)
15
-
16
-
func (s *Spindle) processEvent(ctx context.Context, evt tap.Event) error {
17
-
l := s.l.With("component", "tapIndexer")
18
-
19
-
var err error
20
-
switch evt.Type {
21
-
case tap.EvtRecord:
22
-
switch evt.Record.Collection.String() {
23
-
case tangled.SpindleMemberNSID:
24
-
err = s.processMember(ctx, evt)
25
-
case tangled.RepoNSID:
26
-
err = s.processRepo(ctx, evt)
27
-
case tangled.RepoCollaboratorNSID:
28
-
err = s.processCollaborator(ctx, evt)
29
-
case tangled.RepoPullNSID:
30
-
err = s.processPull(ctx, evt)
31
-
}
32
-
case tap.EvtIdentity:
33
-
// no-op
34
-
}
35
-
36
-
if err != nil {
37
-
l.Error("failed to process message. will retry later", "event.ID", evt.ID, "err", err)
38
-
return err
39
-
}
40
-
return nil
41
-
}
42
-
43
-
// NOTE: make sure to return nil if we don't need to retry (e.g. forbidden, unrelated)
44
-
45
-
func (s *Spindle) processMember(ctx context.Context, evt tap.Event) error {
46
-
l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri())
47
-
48
-
l.Info("processing spindle.member record")
49
-
50
-
// only listen to members
51
-
if ok, err := s.e.IsSpindleMemberInviteAllowed(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil {
52
-
l.Warn("forbidden request: member invite not allowed", "did", evt.Record.Did, "error", err)
53
-
return nil
54
-
}
55
-
56
-
switch evt.Record.Action {
57
-
case tap.RecordCreateAction, tap.RecordUpdateAction:
58
-
record := tangled.SpindleMember{}
59
-
if err := json.Unmarshal(evt.Record.Record, &record); err != nil {
60
-
return fmt.Errorf("parsing record: %w", err)
61
-
}
62
-
63
-
domain := s.cfg.Server.Hostname
64
-
if record.Instance != domain {
65
-
l.Info("domain mismatch", "domain", record.Instance, "expected", domain)
66
-
return nil
67
-
}
68
-
69
-
created, err := time.Parse(record.CreatedAt, time.RFC3339)
70
-
if err != nil {
71
-
created = time.Now()
72
-
}
73
-
if err := db.AddSpindleMember(s.db, db.SpindleMember{
74
-
Did: evt.Record.Did,
75
-
Rkey: evt.Record.Rkey.String(),
76
-
Instance: record.Instance,
77
-
Subject: syntax.DID(record.Subject),
78
-
Created: created,
79
-
}); err != nil {
80
-
l.Error("failed to add member", "error", err)
81
-
return fmt.Errorf("adding member to db: %w", err)
82
-
}
83
-
if err := s.e.AddSpindleMember(syntax.DID(record.Subject), s.cfg.Server.Did()); err != nil {
84
-
return fmt.Errorf("adding member to rbac: %w", err)
85
-
}
86
-
if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil {
87
-
return fmt.Errorf("adding did to tap", err)
88
-
}
89
-
90
-
l.Info("added member", "member", record.Subject)
91
-
return nil
92
-
93
-
case tap.RecordDeleteAction:
94
-
var (
95
-
did = evt.Record.Did.String()
96
-
rkey = evt.Record.Rkey.String()
97
-
)
98
-
member, err := db.GetSpindleMember(s.db, did, rkey)
99
-
if err != nil {
100
-
return fmt.Errorf("finding member: %w", err)
101
-
}
102
-
103
-
if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil {
104
-
return fmt.Errorf("removing member from db: %w", err)
105
-
}
106
-
if err := s.e.RemoveSpindleMember(member.Subject, s.cfg.Server.Did()); err != nil {
107
-
return fmt.Errorf("removing member from rbac: %w", err)
108
-
}
109
-
if err := s.tapSafeRemoveDid(ctx, member.Subject); err != nil {
110
-
return fmt.Errorf("removing did from tap: %w", err)
111
-
}
112
-
113
-
l.Info("removed member", "member", member.Subject)
114
-
return nil
115
-
}
116
-
return nil
117
-
}
118
-
119
-
func (s *Spindle) processCollaborator(ctx context.Context, evt tap.Event) error {
120
-
l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri())
121
-
122
-
l.Info("processing repo.collaborator record")
123
-
124
-
// only listen to members
125
-
if ok, err := s.e.IsSpindleMember(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil {
126
-
l.Warn("forbidden request: not spindle member", "did", evt.Record.Did, "err", err)
127
-
return nil
128
-
}
129
-
130
-
switch evt.Record.Action {
131
-
case tap.RecordCreateAction, tap.RecordUpdateAction:
132
-
record := tangled.RepoCollaborator{}
133
-
if err := json.Unmarshal(evt.Record.Record, &record); err != nil {
134
-
l.Error("invalid record", "err", err)
135
-
return fmt.Errorf("parsing record: %w", err)
136
-
}
137
-
138
-
// retry later if target repo is not ingested yet
139
-
if _, err := s.db.GetRepo(syntax.ATURI(record.Repo)); err != nil {
140
-
l.Warn("target repo is not ingested yet", "repo", record.Repo, "err", err)
141
-
return fmt.Errorf("target repo is unknown")
142
-
}
143
-
144
-
// check perms for this user
145
-
if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, syntax.ATURI(record.Repo)); !ok || err != nil {
146
-
l.Warn("forbidden request collaborator invite not allowed", "did", evt.Record.Did, "err", err)
147
-
return nil
148
-
}
149
-
150
-
if err := s.db.PutRepoCollaborator(&db.RepoCollaborator{
151
-
Did: evt.Record.Did,
152
-
Rkey: evt.Record.Rkey,
153
-
Repo: syntax.ATURI(record.Repo),
154
-
Subject: syntax.DID(record.Subject),
155
-
}); err != nil {
156
-
return fmt.Errorf("adding collaborator to db: %w", err)
157
-
}
158
-
if err := s.e.AddRepoCollaborator(syntax.DID(record.Subject), syntax.ATURI(record.Repo)); err != nil {
159
-
return fmt.Errorf("adding collaborator to rbac: %w", err)
160
-
}
161
-
if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil {
162
-
return fmt.Errorf("adding did to tap: %w", err)
163
-
}
164
-
165
-
l.Info("add repo collaborator", "subejct", record.Subject, "repo", record.Repo)
166
-
return nil
167
-
168
-
case tap.RecordDeleteAction:
169
-
// get existing collaborator
170
-
collaborator, err := s.db.GetRepoCollaborator(evt.Record.Did, evt.Record.Rkey)
171
-
if err != nil {
172
-
return fmt.Errorf("failed to get existing collaborator info: %w", err)
173
-
}
174
-
175
-
// check perms for this user
176
-
if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, collaborator.Repo); !ok || err != nil {
177
-
l.Warn("forbidden request collaborator invite not allowed", "did", evt.Record.Did, "err", err)
178
-
return nil
179
-
}
180
-
181
-
if err := s.db.RemoveRepoCollaborator(collaborator.Subject, collaborator.Rkey); err != nil {
182
-
return fmt.Errorf("removing collaborator from db: %w", err)
183
-
}
184
-
if err := s.e.RemoveRepoCollaborator(collaborator.Subject, collaborator.Repo); err != nil {
185
-
return fmt.Errorf("removing collaborator from rbac: %w", err)
186
-
}
187
-
if err := s.tapSafeRemoveDid(ctx, collaborator.Subject); err != nil {
188
-
return fmt.Errorf("removing did from tap: %w", err)
189
-
}
190
-
191
-
l.Info("removed repo collaborator", "subejct", collaborator.Subject, "repo", collaborator.Repo)
192
-
return nil
193
-
}
194
-
return nil
195
-
}
196
-
197
-
func (s *Spindle) processRepo(ctx context.Context, evt tap.Event) error {
198
-
l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri())
199
-
200
-
l.Info("processing repo record")
201
-
202
-
// only listen to members
203
-
if ok, err := s.e.IsSpindleMember(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil {
204
-
l.Warn("forbidden request: not spindle member", "did", evt.Record.Did, "err", err)
205
-
return nil
206
-
}
207
-
208
-
switch evt.Record.Action {
209
-
case tap.RecordCreateAction, tap.RecordUpdateAction:
210
-
record := tangled.Repo{}
211
-
if err := json.Unmarshal(evt.Record.Record, &record); err != nil {
212
-
return fmt.Errorf("parsing record: %w", err)
213
-
}
214
-
215
-
domain := s.cfg.Server.Hostname
216
-
if record.Spindle == nil || *record.Spindle != domain {
217
-
if record.Spindle == nil {
218
-
l.Info("spindle isn't configured", "name", record.Name)
219
-
} else {
220
-
l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain)
221
-
}
222
-
if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil {
223
-
return fmt.Errorf("deleting repo from db: %w", err)
224
-
}
225
-
return nil
226
-
}
227
-
228
-
if err := s.db.PutRepo(&db.Repo{
229
-
Did: evt.Record.Did,
230
-
Rkey: evt.Record.Rkey,
231
-
Name: record.Name,
232
-
Knot: record.Knot,
233
-
}); err != nil {
234
-
return fmt.Errorf("adding repo to db: %w", err)
235
-
}
236
-
237
-
if err := s.e.AddRepo(evt.Record.AtUri()); err != nil {
238
-
return fmt.Errorf("adding repo to rbac")
239
-
}
240
-
241
-
// add this knot to the event consumer
242
-
src := eventconsumer.NewKnotSource(record.Knot)
243
-
s.ks.AddSource(context.Background(), src)
244
-
245
-
l.Info("added repo", "repo", evt.Record.AtUri())
246
-
return nil
247
-
248
-
case tap.RecordDeleteAction:
249
-
// check perms for this user
250
-
if ok, err := s.e.IsRepoOwner(evt.Record.Did, evt.Record.AtUri()); !ok || err != nil {
251
-
l.Warn("forbidden request: not repo owner", "did", evt.Record.Did, "err", err)
252
-
return nil
253
-
}
254
-
255
-
if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil {
256
-
return fmt.Errorf("deleting repo from db: %w", err)
257
-
}
258
-
259
-
if err := s.e.DeleteRepo(evt.Record.AtUri()); err != nil {
260
-
return fmt.Errorf("deleting repo from rbac: %w", err)
261
-
}
262
-
263
-
l.Info("deleted repo", "repo", evt.Record.AtUri())
264
-
return nil
265
-
}
266
-
return nil
267
-
}
268
-
269
-
func (s *Spindle) processPull(ctx context.Context, evt tap.Event) error {
270
-
l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri())
271
-
272
-
l.Info("processing pull record")
273
-
274
-
switch evt.Record.Action {
275
-
case tap.RecordCreateAction, tap.RecordUpdateAction:
276
-
// TODO
277
-
case tap.RecordDeleteAction:
278
-
// TODO
279
-
}
280
-
return nil
281
-
}
282
-
283
-
func (s *Spindle) tapSafeRemoveDid(ctx context.Context, did syntax.DID) error {
284
-
known, err := s.db.IsKnownDid(syntax.DID(did))
285
-
if err != nil {
286
-
return fmt.Errorf("ensuring did known state: %w", err)
287
-
}
288
-
if !known {
289
-
if err := s.tap.RemoveRepos(ctx, []syntax.DID{did}); err != nil {
290
-
return fmt.Errorf("removing did from tap: %w", err)
291
-
}
292
-
}
293
-
return nil
294
-
}
···
+2
-1
spindle/xrpc/add_secret.go
+2
-1
spindle/xrpc/add_secret.go
···
11
"github.com/bluesky-social/indigo/xrpc"
12
securejoin "github.com/cyphar/filepath-securejoin"
13
"tangled.org/core/api/tangled"
14
"tangled.org/core/spindle/secrets"
15
xrpcerr "tangled.org/core/xrpc/errors"
16
)
···
67
return
68
}
69
70
-
if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil {
71
l.Error("insufficent permissions", "did", actorDid.String())
72
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
73
return
···
11
"github.com/bluesky-social/indigo/xrpc"
12
securejoin "github.com/cyphar/filepath-securejoin"
13
"tangled.org/core/api/tangled"
14
+
"tangled.org/core/rbac"
15
"tangled.org/core/spindle/secrets"
16
xrpcerr "tangled.org/core/xrpc/errors"
17
)
···
68
return
69
}
70
71
+
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
72
l.Error("insufficent permissions", "did", actorDid.String())
73
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
74
return
+2
-1
spindle/xrpc/list_secrets.go
+2
-1
spindle/xrpc/list_secrets.go
···
11
"github.com/bluesky-social/indigo/xrpc"
12
securejoin "github.com/cyphar/filepath-securejoin"
13
"tangled.org/core/api/tangled"
14
"tangled.org/core/spindle/secrets"
15
xrpcerr "tangled.org/core/xrpc/errors"
16
)
···
62
return
63
}
64
65
-
if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil {
66
l.Error("insufficent permissions", "did", actorDid.String())
67
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
68
return
···
11
"github.com/bluesky-social/indigo/xrpc"
12
securejoin "github.com/cyphar/filepath-securejoin"
13
"tangled.org/core/api/tangled"
14
+
"tangled.org/core/rbac"
15
"tangled.org/core/spindle/secrets"
16
xrpcerr "tangled.org/core/xrpc/errors"
17
)
···
63
return
64
}
65
66
+
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
67
l.Error("insufficent permissions", "did", actorDid.String())
68
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
69
return
+1
-1
spindle/xrpc/owner.go
+1
-1
spindle/xrpc/owner.go
-72
spindle/xrpc/pipeline_cancelPipeline.go
-72
spindle/xrpc/pipeline_cancelPipeline.go
···
1
-
package xrpc
2
-
3
-
import (
4
-
"encoding/json"
5
-
"fmt"
6
-
"net/http"
7
-
"strings"
8
-
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"tangled.org/core/api/tangled"
11
-
"tangled.org/core/spindle/models"
12
-
xrpcerr "tangled.org/core/xrpc/errors"
13
-
)
14
-
15
-
func (x *Xrpc) CancelPipeline(w http.ResponseWriter, r *http.Request) {
16
-
l := x.Logger
17
-
fail := func(e xrpcerr.XrpcError) {
18
-
l.Error("failed", "kind", e.Tag, "error", e.Message)
19
-
writeError(w, e, http.StatusBadRequest)
20
-
}
21
-
l.Debug("cancel pipeline")
22
-
23
-
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
24
-
if !ok {
25
-
fail(xrpcerr.MissingActorDidError)
26
-
return
27
-
}
28
-
29
-
var input tangled.PipelineCancelPipeline_Input
30
-
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
31
-
fail(xrpcerr.GenericError(err))
32
-
return
33
-
}
34
-
35
-
aturi := syntax.ATURI(input.Pipeline)
36
-
wid := models.WorkflowId{
37
-
PipelineId: models.PipelineId{
38
-
Knot: strings.TrimPrefix(aturi.Authority().String(), "did:web:"),
39
-
Rkey: aturi.RecordKey().String(),
40
-
},
41
-
Name: input.Workflow,
42
-
}
43
-
l.Debug("cancel pipeline", "wid", wid)
44
-
45
-
// unfortunately we have to resolve repo-at here
46
-
repoAt, err := syntax.ParseATURI(input.Repo)
47
-
if err != nil {
48
-
fail(xrpcerr.InvalidRepoError(input.Repo))
49
-
return
50
-
}
51
-
52
-
isRepoOwner, err := x.Enforcer.IsRepoOwner(actorDid, repoAt)
53
-
if err != nil || !isRepoOwner {
54
-
fail(xrpcerr.AccessControlError(actorDid.String()))
55
-
return
56
-
}
57
-
for _, engine := range x.Engines {
58
-
l.Debug("destorying workflow", "wid", wid)
59
-
err = engine.DestroyWorkflow(r.Context(), wid)
60
-
if err != nil {
61
-
fail(xrpcerr.GenericError(fmt.Errorf("dailed to destroy workflow: %w", err)))
62
-
return
63
-
}
64
-
err = x.Db.StatusCancelled(wid, "User canceled the workflow", -1, x.Notifier)
65
-
if err != nil {
66
-
fail(xrpcerr.GenericError(fmt.Errorf("dailed to emit status failed: %w", err)))
67
-
return
68
-
}
69
-
}
70
-
71
-
w.WriteHeader(http.StatusOK)
72
-
}
···
+2
-1
spindle/xrpc/remove_secret.go
+2
-1
spindle/xrpc/remove_secret.go
···
10
"github.com/bluesky-social/indigo/xrpc"
11
securejoin "github.com/cyphar/filepath-securejoin"
12
"tangled.org/core/api/tangled"
13
"tangled.org/core/spindle/secrets"
14
xrpcerr "tangled.org/core/xrpc/errors"
15
)
···
61
return
62
}
63
64
-
if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil {
65
l.Error("insufficent permissions", "did", actorDid.String())
66
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
67
return
···
10
"github.com/bluesky-social/indigo/xrpc"
11
securejoin "github.com/cyphar/filepath-securejoin"
12
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/rbac"
14
"tangled.org/core/spindle/secrets"
15
xrpcerr "tangled.org/core/xrpc/errors"
16
)
···
62
return
63
}
64
65
+
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
66
l.Error("insufficent permissions", "did", actorDid.String())
67
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
68
return
+2
-5
spindle/xrpc/xrpc.go
+2
-5
spindle/xrpc/xrpc.go
···
10
11
"tangled.org/core/api/tangled"
12
"tangled.org/core/idresolver"
13
-
"tangled.org/core/notifier"
14
-
"tangled.org/core/rbac2"
15
"tangled.org/core/spindle/config"
16
"tangled.org/core/spindle/db"
17
"tangled.org/core/spindle/models"
···
25
type Xrpc struct {
26
Logger *slog.Logger
27
Db *db.DB
28
-
Enforcer *rbac2.Enforcer
29
Engines map[string]models.Engine
30
Config *config.Config
31
Resolver *idresolver.Resolver
32
Vault secrets.Manager
33
-
Notifier *notifier.Notifier
34
ServiceAuth *serviceauth.ServiceAuth
35
}
36
···
43
r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
44
r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
45
r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
46
-
r.Post("/"+tangled.PipelineCancelPipelineNSID, x.CancelPipeline)
47
})
48
49
// service query endpoints (no auth required)
···
10
11
"tangled.org/core/api/tangled"
12
"tangled.org/core/idresolver"
13
+
"tangled.org/core/rbac"
14
"tangled.org/core/spindle/config"
15
"tangled.org/core/spindle/db"
16
"tangled.org/core/spindle/models"
···
24
type Xrpc struct {
25
Logger *slog.Logger
26
Db *db.DB
27
+
Enforcer *rbac.Enforcer
28
Engines map[string]models.Engine
29
Config *config.Config
30
Resolver *idresolver.Resolver
31
Vault secrets.Manager
32
ServiceAuth *serviceauth.ServiceAuth
33
}
34
···
41
r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
42
r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
43
r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
44
})
45
46
// service query endpoints (no auth required)
-24
tap/simpleIndexer.go
-24
tap/simpleIndexer.go
···
1
-
package tap
2
-
3
-
import "context"
4
-
5
-
type SimpleIndexer struct {
6
-
EventHandler func(ctx context.Context, evt Event) error
7
-
ErrorHandler func(ctx context.Context, err error)
8
-
}
9
-
10
-
var _ Handler = (*SimpleIndexer)(nil)
11
-
12
-
func (i *SimpleIndexer) OnEvent(ctx context.Context, evt Event) error {
13
-
if i.EventHandler == nil {
14
-
return nil
15
-
}
16
-
return i.EventHandler(ctx, evt)
17
-
}
18
-
19
-
func (i *SimpleIndexer) OnError(ctx context.Context, err error) {
20
-
if i.ErrorHandler == nil {
21
-
return
22
-
}
23
-
i.ErrorHandler(ctx, err)
24
-
}
···
-169
tap/tap.go
-169
tap/tap.go
···
1
-
/// heavily inspired by <https://github.com/bluesky-social/atproto/blob/c7f5a868837d3e9b3289f988fee2267789327b06/packages/tap/README.md>
2
-
3
-
package tap
4
-
5
-
import (
6
-
"bytes"
7
-
"context"
8
-
"encoding/json"
9
-
"fmt"
10
-
"net/http"
11
-
"net/url"
12
-
13
-
"github.com/bluesky-social/indigo/atproto/syntax"
14
-
"github.com/gorilla/websocket"
15
-
"tangled.org/core/log"
16
-
)
17
-
18
-
// type WebsocketOptions struct {
19
-
// maxReconnectSeconds int
20
-
// heartbeatIntervalMs int
21
-
// // onReconnectError
22
-
// }
23
-
24
-
type Handler interface {
25
-
OnEvent(ctx context.Context, evt Event) error
26
-
OnError(ctx context.Context, err error)
27
-
}
28
-
29
-
type Client struct {
30
-
Url string
31
-
AdminPassword string
32
-
HTTPClient *http.Client
33
-
}
34
-
35
-
func NewClient(url, adminPassword string) Client {
36
-
return Client{
37
-
Url: url,
38
-
AdminPassword: adminPassword,
39
-
HTTPClient: &http.Client{},
40
-
}
41
-
}
42
-
43
-
func (c *Client) AddRepos(ctx context.Context, dids []syntax.DID) error {
44
-
body, err := json.Marshal(map[string][]syntax.DID{"dids": dids})
45
-
if err != nil {
46
-
return err
47
-
}
48
-
req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/add", bytes.NewReader(body))
49
-
if err != nil {
50
-
return err
51
-
}
52
-
req.SetBasicAuth("admin", c.AdminPassword)
53
-
req.Header.Set("Content-Type", "application/json")
54
-
55
-
resp, err := c.HTTPClient.Do(req)
56
-
if err != nil {
57
-
return err
58
-
}
59
-
defer resp.Body.Close()
60
-
if resp.StatusCode != http.StatusOK {
61
-
return fmt.Errorf("tap: /repos/add failed with status %d", resp.StatusCode)
62
-
}
63
-
return nil
64
-
}
65
-
66
-
func (c *Client) RemoveRepos(ctx context.Context, dids []syntax.DID) error {
67
-
body, err := json.Marshal(map[string][]syntax.DID{"dids": dids})
68
-
if err != nil {
69
-
return err
70
-
}
71
-
req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/remove", bytes.NewReader(body))
72
-
if err != nil {
73
-
return err
74
-
}
75
-
req.SetBasicAuth("admin", c.AdminPassword)
76
-
req.Header.Set("Content-Type", "application/json")
77
-
78
-
resp, err := c.HTTPClient.Do(req)
79
-
if err != nil {
80
-
return err
81
-
}
82
-
defer resp.Body.Close()
83
-
if resp.StatusCode != http.StatusOK {
84
-
return fmt.Errorf("tap: /repos/remove failed with status %d", resp.StatusCode)
85
-
}
86
-
return nil
87
-
}
88
-
89
-
func (c *Client) Connect(ctx context.Context, handler Handler) error {
90
-
l := log.FromContext(ctx)
91
-
92
-
u, err := url.Parse(c.Url)
93
-
if err != nil {
94
-
return err
95
-
}
96
-
if u.Scheme == "https" {
97
-
u.Scheme = "wss"
98
-
} else {
99
-
u.Scheme = "ws"
100
-
}
101
-
u.Path = "/channel"
102
-
103
-
// TODO: set auth on dial
104
-
105
-
url := u.String()
106
-
107
-
// var backoff int
108
-
// for {
109
-
// select {
110
-
// case <-ctx.Done():
111
-
// return ctx.Err()
112
-
// default:
113
-
// }
114
-
//
115
-
// header := http.Header{
116
-
// "Authorization": []string{""},
117
-
// }
118
-
// conn, res, err := websocket.DefaultDialer.DialContext(ctx, url, header)
119
-
// if err != nil {
120
-
// l.Warn("dialing failed", "url", url, "err", err, "backoff", backoff)
121
-
// time.Sleep(time.Duration(5+backoff) * time.Second)
122
-
// backoff++
123
-
//
124
-
// continue
125
-
// } else {
126
-
// backoff = 0
127
-
// }
128
-
//
129
-
// l.Info("event subscription response", "code", res.StatusCode)
130
-
// }
131
-
132
-
// TODO: keep websocket connection alive
133
-
conn, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil)
134
-
if err != nil {
135
-
return err
136
-
}
137
-
defer conn.Close()
138
-
139
-
for {
140
-
select {
141
-
case <-ctx.Done():
142
-
return ctx.Err()
143
-
default:
144
-
}
145
-
_, message, err := conn.ReadMessage()
146
-
if err != nil {
147
-
return err
148
-
}
149
-
150
-
var ev Event
151
-
if err := json.Unmarshal(message, &ev); err != nil {
152
-
handler.OnError(ctx, fmt.Errorf("failed to parse message: %w", err))
153
-
continue
154
-
}
155
-
if err := handler.OnEvent(ctx, ev); err != nil {
156
-
handler.OnError(ctx, fmt.Errorf("failed to process event %d: %w", ev.ID, err))
157
-
continue
158
-
}
159
-
160
-
ack := map[string]any{
161
-
"type": "ack",
162
-
"id": ev.ID,
163
-
}
164
-
if err := conn.WriteJSON(ack); err != nil {
165
-
l.Warn("failed to send ack", "err", err)
166
-
continue
167
-
}
168
-
}
169
-
}
···
-62
tap/types.go
-62
tap/types.go
···
1
-
package tap
2
-
3
-
import (
4
-
"encoding/json"
5
-
"fmt"
6
-
7
-
"github.com/bluesky-social/indigo/atproto/syntax"
8
-
)
9
-
10
-
type EventType string
11
-
12
-
const (
13
-
EvtRecord EventType = "record"
14
-
EvtIdentity EventType = "identity"
15
-
)
16
-
17
-
type Event struct {
18
-
ID int64 `json:"id"`
19
-
Type EventType `json:"type"`
20
-
Record *RecordEventData `json:"record,omitempty"`
21
-
Identity *IdentityEventData `json:"identity,omitempty"`
22
-
}
23
-
24
-
type RecordEventData struct {
25
-
Live bool `json:"live"`
26
-
Did syntax.DID `json:"did"`
27
-
Rev string `json:"rev"`
28
-
Collection syntax.NSID `json:"collection"`
29
-
Rkey syntax.RecordKey `json:"rkey"`
30
-
Action RecordAction `json:"action"`
31
-
Record json.RawMessage `json:"record,omitempty"`
32
-
CID *syntax.CID `json:"cid,omitempty"`
33
-
}
34
-
35
-
func (r *RecordEventData) AtUri() syntax.ATURI {
36
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, r.Collection, r.Rkey))
37
-
}
38
-
39
-
type RecordAction string
40
-
41
-
const (
42
-
RecordCreateAction RecordAction = "create"
43
-
RecordUpdateAction RecordAction = "update"
44
-
RecordDeleteAction RecordAction = "delete"
45
-
)
46
-
47
-
type IdentityEventData struct {
48
-
DID syntax.DID `json:"did"`
49
-
Handle string `json:"handle"`
50
-
IsActive bool `json:"is_active"`
51
-
Status RepoStatus `json:"status"`
52
-
}
53
-
54
-
type RepoStatus string
55
-
56
-
const (
57
-
RepoStatusActive RepoStatus = "active"
58
-
RepoStatusTakendown RepoStatus = "takendown"
59
-
RepoStatusSuspended RepoStatus = "suspended"
60
-
RepoStatusDeactivated RepoStatus = "deactivated"
61
-
RepoStatusDeleted RepoStatus = "deleted"
62
-
)
···
+3
types/diff.go
+3
types/diff.go
+112
types/diff_test.go
+112
types/diff_test.go
···
···
1
+
package types
2
+
3
+
import "testing"
4
+
5
+
func TestDiffId(t *testing.T) {
6
+
tests := []struct {
7
+
name string
8
+
diff Diff
9
+
expected string
10
+
}{
11
+
{
12
+
name: "regular file uses new name",
13
+
diff: Diff{
14
+
Name: struct {
15
+
Old string `json:"old"`
16
+
New string `json:"new"`
17
+
}{Old: "", New: "src/main.go"},
18
+
},
19
+
expected: "src/main.go",
20
+
},
21
+
{
22
+
name: "new file uses new name",
23
+
diff: Diff{
24
+
Name: struct {
25
+
Old string `json:"old"`
26
+
New string `json:"new"`
27
+
}{Old: "", New: "src/new.go"},
28
+
IsNew: true,
29
+
},
30
+
expected: "src/new.go",
31
+
},
32
+
{
33
+
name: "deleted file uses old name",
34
+
diff: Diff{
35
+
Name: struct {
36
+
Old string `json:"old"`
37
+
New string `json:"new"`
38
+
}{Old: "src/deleted.go", New: ""},
39
+
IsDelete: true,
40
+
},
41
+
expected: "src/deleted.go",
42
+
},
43
+
{
44
+
name: "renamed file uses new name",
45
+
diff: Diff{
46
+
Name: struct {
47
+
Old string `json:"old"`
48
+
New string `json:"new"`
49
+
}{Old: "src/old.go", New: "src/renamed.go"},
50
+
IsRename: true,
51
+
},
52
+
expected: "src/renamed.go",
53
+
},
54
+
}
55
+
56
+
for _, tt := range tests {
57
+
t.Run(tt.name, func(t *testing.T) {
58
+
if got := tt.diff.Id(); got != tt.expected {
59
+
t.Errorf("Diff.Id() = %q, want %q", got, tt.expected)
60
+
}
61
+
})
62
+
}
63
+
}
64
+
65
+
func TestChangedFilesMatchesDiffId(t *testing.T) {
66
+
// ChangedFiles() must return values matching each Diff's Id()
67
+
// so that sidebar links point to the correct anchors.
68
+
// Tests existing, deleted, new, and renamed files.
69
+
nd := NiceDiff{
70
+
Diff: []Diff{
71
+
{
72
+
Name: struct {
73
+
Old string `json:"old"`
74
+
New string `json:"new"`
75
+
}{Old: "", New: "src/modified.go"},
76
+
},
77
+
{
78
+
Name: struct {
79
+
Old string `json:"old"`
80
+
New string `json:"new"`
81
+
}{Old: "src/deleted.go", New: ""},
82
+
IsDelete: true,
83
+
},
84
+
{
85
+
Name: struct {
86
+
Old string `json:"old"`
87
+
New string `json:"new"`
88
+
}{Old: "", New: "src/new.go"},
89
+
IsNew: true,
90
+
},
91
+
{
92
+
Name: struct {
93
+
Old string `json:"old"`
94
+
New string `json:"new"`
95
+
}{Old: "src/old.go", New: "src/renamed.go"},
96
+
IsRename: true,
97
+
},
98
+
},
99
+
}
100
+
101
+
changedFiles := nd.ChangedFiles()
102
+
103
+
if len(changedFiles) != len(nd.Diff) {
104
+
t.Fatalf("ChangedFiles() returned %d items, want %d", len(changedFiles), len(nd.Diff))
105
+
}
106
+
107
+
for i, diff := range nd.Diff {
108
+
if changedFiles[i] != diff.Id() {
109
+
t.Errorf("ChangedFiles()[%d] = %q, but Diff.Id() = %q", i, changedFiles[i], diff.Id())
110
+
}
111
+
}
112
+
}