+232
api/tangled/cbor_gen.go
+232
api/tangled/cbor_gen.go
···
8423
8423
8424
8424
return nil
8425
8425
}
8426
+
func (t *String) MarshalCBOR(w io.Writer) error {
8427
+
if t == nil {
8428
+
_, err := w.Write(cbg.CborNull)
8429
+
return err
8430
+
}
8431
+
8432
+
cw := cbg.NewCborWriter(w)
8433
+
8434
+
if _, err := cw.Write([]byte{165}); err != nil {
8435
+
return err
8436
+
}
8437
+
8438
+
// t.LexiconTypeID (string) (string)
8439
+
if len("$type") > 1000000 {
8440
+
return xerrors.Errorf("Value in field \"$type\" was too long")
8441
+
}
8442
+
8443
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
8444
+
return err
8445
+
}
8446
+
if _, err := cw.WriteString(string("$type")); err != nil {
8447
+
return err
8448
+
}
8449
+
8450
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil {
8451
+
return err
8452
+
}
8453
+
if _, err := cw.WriteString(string("sh.tangled.string")); err != nil {
8454
+
return err
8455
+
}
8456
+
8457
+
// t.Contents (string) (string)
8458
+
if len("contents") > 1000000 {
8459
+
return xerrors.Errorf("Value in field \"contents\" was too long")
8460
+
}
8461
+
8462
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil {
8463
+
return err
8464
+
}
8465
+
if _, err := cw.WriteString(string("contents")); err != nil {
8466
+
return err
8467
+
}
8468
+
8469
+
if len(t.Contents) > 1000000 {
8470
+
return xerrors.Errorf("Value in field t.Contents was too long")
8471
+
}
8472
+
8473
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil {
8474
+
return err
8475
+
}
8476
+
if _, err := cw.WriteString(string(t.Contents)); err != nil {
8477
+
return err
8478
+
}
8479
+
8480
+
// t.Filename (string) (string)
8481
+
if len("filename") > 1000000 {
8482
+
return xerrors.Errorf("Value in field \"filename\" was too long")
8483
+
}
8484
+
8485
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil {
8486
+
return err
8487
+
}
8488
+
if _, err := cw.WriteString(string("filename")); err != nil {
8489
+
return err
8490
+
}
8491
+
8492
+
if len(t.Filename) > 1000000 {
8493
+
return xerrors.Errorf("Value in field t.Filename was too long")
8494
+
}
8495
+
8496
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil {
8497
+
return err
8498
+
}
8499
+
if _, err := cw.WriteString(string(t.Filename)); err != nil {
8500
+
return err
8501
+
}
8502
+
8503
+
// t.CreatedAt (string) (string)
8504
+
if len("createdAt") > 1000000 {
8505
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
8506
+
}
8507
+
8508
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
8509
+
return err
8510
+
}
8511
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
8512
+
return err
8513
+
}
8514
+
8515
+
if len(t.CreatedAt) > 1000000 {
8516
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
8517
+
}
8518
+
8519
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
8520
+
return err
8521
+
}
8522
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
8523
+
return err
8524
+
}
8525
+
8526
+
// t.Description (string) (string)
8527
+
if len("description") > 1000000 {
8528
+
return xerrors.Errorf("Value in field \"description\" was too long")
8529
+
}
8530
+
8531
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil {
8532
+
return err
8533
+
}
8534
+
if _, err := cw.WriteString(string("description")); err != nil {
8535
+
return err
8536
+
}
8537
+
8538
+
if len(t.Description) > 1000000 {
8539
+
return xerrors.Errorf("Value in field t.Description was too long")
8540
+
}
8541
+
8542
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil {
8543
+
return err
8544
+
}
8545
+
if _, err := cw.WriteString(string(t.Description)); err != nil {
8546
+
return err
8547
+
}
8548
+
return nil
8549
+
}
8550
+
8551
+
func (t *String) UnmarshalCBOR(r io.Reader) (err error) {
8552
+
*t = String{}
8553
+
8554
+
cr := cbg.NewCborReader(r)
8555
+
8556
+
maj, extra, err := cr.ReadHeader()
8557
+
if err != nil {
8558
+
return err
8559
+
}
8560
+
defer func() {
8561
+
if err == io.EOF {
8562
+
err = io.ErrUnexpectedEOF
8563
+
}
8564
+
}()
8565
+
8566
+
if maj != cbg.MajMap {
8567
+
return fmt.Errorf("cbor input should be of type map")
8568
+
}
8569
+
8570
+
if extra > cbg.MaxLength {
8571
+
return fmt.Errorf("String: map struct too large (%d)", extra)
8572
+
}
8573
+
8574
+
n := extra
8575
+
8576
+
nameBuf := make([]byte, 11)
8577
+
for i := uint64(0); i < n; i++ {
8578
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
8579
+
if err != nil {
8580
+
return err
8581
+
}
8582
+
8583
+
if !ok {
8584
+
// Field doesn't exist on this type, so ignore it
8585
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
8586
+
return err
8587
+
}
8588
+
continue
8589
+
}
8590
+
8591
+
switch string(nameBuf[:nameLen]) {
8592
+
// t.LexiconTypeID (string) (string)
8593
+
case "$type":
8594
+
8595
+
{
8596
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8597
+
if err != nil {
8598
+
return err
8599
+
}
8600
+
8601
+
t.LexiconTypeID = string(sval)
8602
+
}
8603
+
// t.Contents (string) (string)
8604
+
case "contents":
8605
+
8606
+
{
8607
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8608
+
if err != nil {
8609
+
return err
8610
+
}
8611
+
8612
+
t.Contents = string(sval)
8613
+
}
8614
+
// t.Filename (string) (string)
8615
+
case "filename":
8616
+
8617
+
{
8618
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8619
+
if err != nil {
8620
+
return err
8621
+
}
8622
+
8623
+
t.Filename = string(sval)
8624
+
}
8625
+
// t.CreatedAt (string) (string)
8626
+
case "createdAt":
8627
+
8628
+
{
8629
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8630
+
if err != nil {
8631
+
return err
8632
+
}
8633
+
8634
+
t.CreatedAt = string(sval)
8635
+
}
8636
+
// t.Description (string) (string)
8637
+
case "description":
8638
+
8639
+
{
8640
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8641
+
if err != nil {
8642
+
return err
8643
+
}
8644
+
8645
+
t.Description = string(sval)
8646
+
}
8647
+
8648
+
default:
8649
+
// Field doesn't exist on this type, so ignore it
8650
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
8651
+
return err
8652
+
}
8653
+
}
8654
+
}
8655
+
8656
+
return nil
8657
+
}
+25
api/tangled/tangledstring.go
+25
api/tangled/tangledstring.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.string
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
StringNSID = "sh.tangled.string"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.string", &String{})
17
+
} //
18
+
// RECORDTYPE: String
19
+
type String struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"`
21
+
Contents string `json:"contents" cborgen:"contents"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Description string `json:"description" cborgen:"description"`
24
+
Filename string `json:"filename" cborgen:"filename"`
25
+
}
+3
appview/config/config.go
+3
appview/config/config.go
···
16
16
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
17
17
Dev bool `env:"DEV, default=false"`
18
18
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
19
+
20
+
// temporarily, to add users to default spindle
21
+
AppPassword string `env:"APP_PASSWORD"`
19
22
}
20
23
21
24
type OAuthConfig struct {
+1
-1
appview/db/collaborators.go
+1
-1
appview/db/collaborators.go
···
50
50
}
51
51
52
52
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
53
-
rows, err := e.Query(`select repo_at from collaborators where did = ?`, collaborator)
53
+
rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator)
54
54
if err != nil {
55
55
return nil, err
56
56
}
+18
-3
appview/db/db.go
+18
-3
appview/db/db.go
···
443
443
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
444
444
);
445
445
446
+
create table if not exists strings (
447
+
-- identifiers
448
+
did text not null,
449
+
rkey text not null,
450
+
451
+
-- content
452
+
filename text not null,
453
+
description text,
454
+
content text not null,
455
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
456
+
edited text,
457
+
458
+
primary key (did, rkey)
459
+
);
460
+
446
461
create table if not exists migrations (
447
462
id integer primary key autoincrement,
448
463
name text unique
···
603
618
repo_at text not null,
604
619
605
620
-- meta
606
-
created text default (strftime('%y-%m-%dt%h:%m:%sz', 'now')),
621
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
607
622
608
623
-- constraints
609
624
foreign key (repo_at) references repos(at_uri) on delete cascade
···
713
728
kind := rv.Kind()
714
729
715
730
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
716
-
if kind == reflect.Slice || kind == reflect.Array {
731
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
717
732
if rv.Len() == 0 {
718
733
// always false
719
734
return "1 = 0"
···
733
748
func (f filter) Arg() []any {
734
749
rv := reflect.ValueOf(f.arg)
735
750
kind := rv.Kind()
736
-
if kind == reflect.Slice || kind == reflect.Array {
751
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
737
752
if rv.Len() == 0 {
738
753
return nil
739
754
}
+251
appview/db/strings.go
+251
appview/db/strings.go
···
1
+
package db
2
+
3
+
import (
4
+
"bytes"
5
+
"database/sql"
6
+
"errors"
7
+
"fmt"
8
+
"io"
9
+
"strings"
10
+
"time"
11
+
"unicode/utf8"
12
+
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
"tangled.sh/tangled.sh/core/api/tangled"
15
+
)
16
+
17
+
type String struct {
18
+
Did syntax.DID
19
+
Rkey string
20
+
21
+
Filename string
22
+
Description string
23
+
Contents string
24
+
Created time.Time
25
+
Edited *time.Time
26
+
}
27
+
28
+
func (s *String) StringAt() syntax.ATURI {
29
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
30
+
}
31
+
32
+
type StringStats struct {
33
+
LineCount uint64
34
+
ByteCount uint64
35
+
}
36
+
37
+
func (s String) Stats() StringStats {
38
+
lineCount, err := countLines(strings.NewReader(s.Contents))
39
+
if err != nil {
40
+
// non-fatal
41
+
// TODO: log this?
42
+
}
43
+
44
+
return StringStats{
45
+
LineCount: uint64(lineCount),
46
+
ByteCount: uint64(len(s.Contents)),
47
+
}
48
+
}
49
+
50
+
func (s String) Validate() error {
51
+
var err error
52
+
53
+
if !strings.Contains(s.Filename, ".") {
54
+
err = errors.Join(err, fmt.Errorf("missing filename extension"))
55
+
}
56
+
57
+
if strings.HasSuffix(s.Filename, ".") {
58
+
err = errors.Join(err, fmt.Errorf("filename ends with `.`"))
59
+
}
60
+
61
+
if utf8.RuneCountInString(s.Filename) > 140 {
62
+
err = errors.Join(err, fmt.Errorf("filename too long"))
63
+
}
64
+
65
+
if utf8.RuneCountInString(s.Description) > 280 {
66
+
err = errors.Join(err, fmt.Errorf("description too long"))
67
+
}
68
+
69
+
if len(s.Contents) == 0 {
70
+
err = errors.Join(err, fmt.Errorf("contents is empty"))
71
+
}
72
+
73
+
return err
74
+
}
75
+
76
+
func (s *String) AsRecord() tangled.String {
77
+
return tangled.String{
78
+
Filename: s.Filename,
79
+
Description: s.Description,
80
+
Contents: s.Contents,
81
+
CreatedAt: s.Created.Format(time.RFC3339),
82
+
}
83
+
}
84
+
85
+
func StringFromRecord(did, rkey string, record tangled.String) String {
86
+
created, err := time.Parse(record.CreatedAt, time.RFC3339)
87
+
if err != nil {
88
+
created = time.Now()
89
+
}
90
+
return String{
91
+
Did: syntax.DID(did),
92
+
Rkey: rkey,
93
+
Filename: record.Filename,
94
+
Description: record.Description,
95
+
Contents: record.Contents,
96
+
Created: created,
97
+
}
98
+
}
99
+
100
+
func AddString(e Execer, s String) error {
101
+
_, err := e.Exec(
102
+
`insert into strings (
103
+
did,
104
+
rkey,
105
+
filename,
106
+
description,
107
+
content,
108
+
created,
109
+
edited
110
+
)
111
+
values (?, ?, ?, ?, ?, ?, null)
112
+
on conflict(did, rkey) do update set
113
+
filename = excluded.filename,
114
+
description = excluded.description,
115
+
content = excluded.content,
116
+
edited = case
117
+
when
118
+
strings.content != excluded.content
119
+
or strings.filename != excluded.filename
120
+
or strings.description != excluded.description then ?
121
+
else strings.edited
122
+
end`,
123
+
s.Did,
124
+
s.Rkey,
125
+
s.Filename,
126
+
s.Description,
127
+
s.Contents,
128
+
s.Created.Format(time.RFC3339),
129
+
time.Now().Format(time.RFC3339),
130
+
)
131
+
return err
132
+
}
133
+
134
+
func GetStrings(e Execer, filters ...filter) ([]String, error) {
135
+
var all []String
136
+
137
+
var conditions []string
138
+
var args []any
139
+
for _, filter := range filters {
140
+
conditions = append(conditions, filter.Condition())
141
+
args = append(args, filter.Arg()...)
142
+
}
143
+
144
+
whereClause := ""
145
+
if conditions != nil {
146
+
whereClause = " where " + strings.Join(conditions, " and ")
147
+
}
148
+
149
+
query := fmt.Sprintf(`select
150
+
did,
151
+
rkey,
152
+
filename,
153
+
description,
154
+
content,
155
+
created,
156
+
edited
157
+
from strings %s`,
158
+
whereClause,
159
+
)
160
+
161
+
rows, err := e.Query(query, args...)
162
+
163
+
if err != nil {
164
+
return nil, err
165
+
}
166
+
defer rows.Close()
167
+
168
+
for rows.Next() {
169
+
var s String
170
+
var createdAt string
171
+
var editedAt sql.NullString
172
+
173
+
if err := rows.Scan(
174
+
&s.Did,
175
+
&s.Rkey,
176
+
&s.Filename,
177
+
&s.Description,
178
+
&s.Contents,
179
+
&createdAt,
180
+
&editedAt,
181
+
); err != nil {
182
+
return nil, err
183
+
}
184
+
185
+
s.Created, err = time.Parse(time.RFC3339, createdAt)
186
+
if err != nil {
187
+
s.Created = time.Now()
188
+
}
189
+
190
+
if editedAt.Valid {
191
+
e, err := time.Parse(time.RFC3339, editedAt.String)
192
+
if err != nil {
193
+
e = time.Now()
194
+
}
195
+
s.Edited = &e
196
+
}
197
+
198
+
all = append(all, s)
199
+
}
200
+
201
+
if err := rows.Err(); err != nil {
202
+
return nil, err
203
+
}
204
+
205
+
return all, nil
206
+
}
207
+
208
+
func DeleteString(e Execer, filters ...filter) error {
209
+
var conditions []string
210
+
var args []any
211
+
for _, filter := range filters {
212
+
conditions = append(conditions, filter.Condition())
213
+
args = append(args, filter.Arg()...)
214
+
}
215
+
216
+
whereClause := ""
217
+
if conditions != nil {
218
+
whereClause = " where " + strings.Join(conditions, " and ")
219
+
}
220
+
221
+
query := fmt.Sprintf(`delete from strings %s`, whereClause)
222
+
223
+
_, err := e.Exec(query, args...)
224
+
return err
225
+
}
226
+
227
+
func countLines(r io.Reader) (int, error) {
228
+
buf := make([]byte, 32*1024)
229
+
bufLen := 0
230
+
count := 0
231
+
nl := []byte{'\n'}
232
+
233
+
for {
234
+
c, err := r.Read(buf)
235
+
if c > 0 {
236
+
bufLen += c
237
+
}
238
+
count += bytes.Count(buf[:c], nl)
239
+
240
+
switch {
241
+
case err == io.EOF:
242
+
/* handle last line not having a newline at the end */
243
+
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
244
+
count++
245
+
}
246
+
return count, nil
247
+
case err != nil:
248
+
return 0, err
249
+
}
250
+
}
251
+
}
+60
appview/ingester.go
+60
appview/ingester.go
···
64
64
err = i.ingestSpindleMember(e)
65
65
case tangled.SpindleNSID:
66
66
err = i.ingestSpindle(e)
67
+
case tangled.StringNSID:
68
+
err = i.ingestString(e)
67
69
}
68
70
l = i.Logger.With("nsid", e.Commit.Collection)
69
71
}
···
385
387
if err != nil {
386
388
return fmt.Errorf("failed to update ACLs: %w", err)
387
389
}
390
+
391
+
l.Info("added spindle member")
388
392
case models.CommitOperationDelete:
389
393
rkey := e.Commit.RKey
390
394
···
431
435
if err = i.Enforcer.E.SavePolicy(); err != nil {
432
436
return fmt.Errorf("failed to save ACLs: %w", err)
433
437
}
438
+
439
+
l.Info("removed spindle member")
434
440
}
435
441
436
442
return nil
···
549
555
550
556
return nil
551
557
}
558
+
559
+
func (i *Ingester) ingestString(e *models.Event) error {
560
+
did := e.Did
561
+
rkey := e.Commit.RKey
562
+
563
+
var err error
564
+
565
+
l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
566
+
l.Info("ingesting record")
567
+
568
+
ddb, ok := i.Db.Execer.(*db.DB)
569
+
if !ok {
570
+
return fmt.Errorf("failed to index string record, invalid db cast")
571
+
}
572
+
573
+
switch e.Commit.Operation {
574
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
575
+
raw := json.RawMessage(e.Commit.Record)
576
+
record := tangled.String{}
577
+
err = json.Unmarshal(raw, &record)
578
+
if err != nil {
579
+
l.Error("invalid record", "err", err)
580
+
return err
581
+
}
582
+
583
+
string := db.StringFromRecord(did, rkey, record)
584
+
585
+
if err = string.Validate(); err != nil {
586
+
l.Error("invalid record", "err", err)
587
+
return err
588
+
}
589
+
590
+
if err = db.AddString(ddb, string); err != nil {
591
+
l.Error("failed to add string", "err", err)
592
+
return err
593
+
}
594
+
595
+
return nil
596
+
597
+
case models.CommitOperationDelete:
598
+
if err := db.DeleteString(
599
+
ddb,
600
+
db.FilterEq("did", did),
601
+
db.FilterEq("rkey", rkey),
602
+
); err != nil {
603
+
l.Error("failed to delete", "err", err)
604
+
return fmt.Errorf("failed to delete string record: %w", err)
605
+
}
606
+
607
+
return nil
608
+
}
609
+
610
+
return nil
611
+
}
+2
-10
appview/middleware/middleware.go
+2
-10
appview/middleware/middleware.go
···
167
167
}
168
168
}
169
169
170
-
func StripLeadingAt(next http.Handler) http.Handler {
171
-
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
172
-
path := req.URL.EscapedPath()
173
-
if strings.HasPrefix(path, "/@") {
174
-
req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@")
175
-
}
176
-
next.ServeHTTP(w, req)
177
-
})
178
-
}
179
-
180
170
func (mw Middleware) ResolveIdent() middlewareFunc {
181
171
excluded := []string{"favicon.ico"}
182
172
···
187
177
next.ServeHTTP(w, req)
188
178
return
189
179
}
180
+
181
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
190
182
191
183
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
192
184
if err != nil {
+141
appview/oauth/handler/handler.go
+141
appview/oauth/handler/handler.go
···
1
1
package oauth
2
2
3
3
import (
4
+
"bytes"
5
+
"context"
4
6
"encoding/json"
5
7
"fmt"
6
8
"log"
7
9
"net/http"
8
10
"net/url"
9
11
"strings"
12
+
"time"
10
13
11
14
"github.com/go-chi/chi/v5"
12
15
"github.com/gorilla/sessions"
13
16
"github.com/lestrrat-go/jwx/v2/jwk"
14
17
"github.com/posthog/posthog-go"
15
18
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
19
+
tangled "tangled.sh/tangled.sh/core/api/tangled"
16
20
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
17
21
"tangled.sh/tangled.sh/core/appview/config"
18
22
"tangled.sh/tangled.sh/core/appview/db"
···
23
27
"tangled.sh/tangled.sh/core/idresolver"
24
28
"tangled.sh/tangled.sh/core/knotclient"
25
29
"tangled.sh/tangled.sh/core/rbac"
30
+
"tangled.sh/tangled.sh/core/tid"
26
31
)
27
32
28
33
const (
···
294
299
295
300
log.Println("session saved successfully")
296
301
go o.addToDefaultKnot(oauthRequest.Did)
302
+
go o.addToDefaultSpindle(oauthRequest.Did)
297
303
298
304
if !o.config.Core.Dev {
299
305
err = o.posthog.Enqueue(posthog.Capture{
···
330
336
return nil, err
331
337
}
332
338
return pubKey, nil
339
+
}
340
+
341
+
func (o *OAuthHandler) addToDefaultSpindle(did string) {
342
+
// use the tangled.sh app password to get an accessJwt
343
+
// and create an sh.tangled.spindle.member record with that
344
+
345
+
defaultSpindle := "spindle.tangled.sh"
346
+
appPassword := o.config.Core.AppPassword
347
+
348
+
spindleMembers, err := db.GetSpindleMembers(
349
+
o.db,
350
+
db.FilterEq("instance", "spindle.tangled.sh"),
351
+
db.FilterEq("subject", did),
352
+
)
353
+
if err != nil {
354
+
log.Printf("failed to get spindle members for did %s: %v", did, err)
355
+
return
356
+
}
357
+
358
+
if len(spindleMembers) != 0 {
359
+
log.Printf("did %s is already a member of the default spindle", did)
360
+
return
361
+
}
362
+
363
+
// TODO: hardcoded tangled handle and did for now
364
+
tangledHandle := "tangled.sh"
365
+
tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli"
366
+
367
+
if appPassword == "" {
368
+
log.Println("no app password configured, skipping spindle member addition")
369
+
return
370
+
}
371
+
372
+
log.Printf("adding %s to default spindle", did)
373
+
374
+
resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid)
375
+
if err != nil {
376
+
log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
377
+
return
378
+
}
379
+
380
+
pdsEndpoint := resolved.PDSEndpoint()
381
+
if pdsEndpoint == "" {
382
+
log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
383
+
return
384
+
}
385
+
386
+
sessionPayload := map[string]string{
387
+
"identifier": tangledHandle,
388
+
"password": appPassword,
389
+
}
390
+
sessionBytes, err := json.Marshal(sessionPayload)
391
+
if err != nil {
392
+
log.Printf("failed to marshal session payload: %v", err)
393
+
return
394
+
}
395
+
396
+
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
397
+
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
398
+
if err != nil {
399
+
log.Printf("failed to create session request: %v", err)
400
+
return
401
+
}
402
+
sessionReq.Header.Set("Content-Type", "application/json")
403
+
404
+
client := &http.Client{Timeout: 30 * time.Second}
405
+
sessionResp, err := client.Do(sessionReq)
406
+
if err != nil {
407
+
log.Printf("failed to create session: %v", err)
408
+
return
409
+
}
410
+
defer sessionResp.Body.Close()
411
+
412
+
if sessionResp.StatusCode != http.StatusOK {
413
+
log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode)
414
+
return
415
+
}
416
+
417
+
var session struct {
418
+
AccessJwt string `json:"accessJwt"`
419
+
}
420
+
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
421
+
log.Printf("failed to decode session response: %v", err)
422
+
return
423
+
}
424
+
425
+
record := tangled.SpindleMember{
426
+
LexiconTypeID: "sh.tangled.spindle.member",
427
+
Subject: did,
428
+
Instance: defaultSpindle,
429
+
CreatedAt: time.Now().Format(time.RFC3339),
430
+
}
431
+
432
+
recordBytes, err := json.Marshal(record)
433
+
if err != nil {
434
+
log.Printf("failed to marshal spindle member record: %v", err)
435
+
return
436
+
}
437
+
438
+
payload := map[string]interface{}{
439
+
"repo": tangledDid,
440
+
"collection": tangled.SpindleMemberNSID,
441
+
"rkey": tid.TID(),
442
+
"record": json.RawMessage(recordBytes),
443
+
}
444
+
445
+
payloadBytes, err := json.Marshal(payload)
446
+
if err != nil {
447
+
log.Printf("failed to marshal request payload: %v", err)
448
+
return
449
+
}
450
+
451
+
url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
452
+
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
453
+
if err != nil {
454
+
log.Printf("failed to create HTTP request: %v", err)
455
+
return
456
+
}
457
+
458
+
req.Header.Set("Content-Type", "application/json")
459
+
req.Header.Set("Authorization", "Bearer "+session.AccessJwt)
460
+
461
+
resp, err := client.Do(req)
462
+
if err != nil {
463
+
log.Printf("failed to add user to default spindle: %v", err)
464
+
return
465
+
}
466
+
defer resp.Body.Close()
467
+
468
+
if resp.StatusCode != http.StatusOK {
469
+
log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode)
470
+
return
471
+
}
472
+
473
+
log.Printf("successfully added %s to default spindle", did)
333
474
}
334
475
335
476
func (o *OAuthHandler) addToDefaultKnot(did string) {
+79
-4
appview/pages/pages.go
+79
-4
appview/pages/pages.go
···
31
31
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
32
32
"github.com/alecthomas/chroma/v2/lexers"
33
33
"github.com/alecthomas/chroma/v2/styles"
34
+
"github.com/bluesky-social/indigo/atproto/identity"
34
35
"github.com/bluesky-social/indigo/atproto/syntax"
35
36
"github.com/go-git/go-git/v5/plumbing"
36
37
"github.com/go-git/go-git/v5/plumbing/object"
···
262
263
return p.executePlain("user/login", w, params)
263
264
}
264
265
265
-
type SignupParams struct{}
266
+
func (p *Pages) Signup(w io.Writer) error {
267
+
return p.executePlain("user/signup", w, nil)
268
+
}
266
269
267
-
func (p *Pages) CompleteSignup(w io.Writer, params SignupParams) error {
268
-
return p.executePlain("user/completeSignup", w, params)
270
+
func (p *Pages) CompleteSignup(w io.Writer) error {
271
+
return p.executePlain("user/completeSignup", w, nil)
269
272
}
270
273
271
274
type TermsOfServiceParams struct {
···
413
416
UserDid string
414
417
UserHandle string
415
418
FollowStatus db.FollowStatus
416
-
AvatarUri string
417
419
Followers int
418
420
Following int
419
421
···
1140
1142
func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
1141
1143
params.Active = "pipelines"
1142
1144
return p.executeRepo("repo/pipelines/workflow", w, params)
1145
+
}
1146
+
1147
+
type PutStringParams struct {
1148
+
LoggedInUser *oauth.User
1149
+
Action string
1150
+
1151
+
// this is supplied in the case of editing an existing string
1152
+
String db.String
1153
+
}
1154
+
1155
+
func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
1156
+
return p.execute("strings/put", w, params)
1157
+
}
1158
+
1159
+
type StringsDashboardParams struct {
1160
+
LoggedInUser *oauth.User
1161
+
Card ProfileCard
1162
+
Strings []db.String
1163
+
}
1164
+
1165
+
func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
1166
+
return p.execute("strings/dashboard", w, params)
1167
+
}
1168
+
1169
+
type SingleStringParams struct {
1170
+
LoggedInUser *oauth.User
1171
+
ShowRendered bool
1172
+
RenderToggle bool
1173
+
RenderedContents template.HTML
1174
+
String db.String
1175
+
Stats db.StringStats
1176
+
Owner identity.Identity
1177
+
}
1178
+
1179
+
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1180
+
var style *chroma.Style = styles.Get("catpuccin-latte")
1181
+
1182
+
if params.ShowRendered {
1183
+
switch markup.GetFormat(params.String.Filename) {
1184
+
case markup.FormatMarkdown:
1185
+
p.rctx.RendererType = markup.RendererTypeDefault
1186
+
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
1187
+
params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString))
1188
+
}
1189
+
}
1190
+
1191
+
c := params.String.Contents
1192
+
formatter := chromahtml.New(
1193
+
chromahtml.InlineCode(false),
1194
+
chromahtml.WithLineNumbers(true),
1195
+
chromahtml.WithLinkableLineNumbers(true, "L"),
1196
+
chromahtml.Standalone(false),
1197
+
chromahtml.WithClasses(true),
1198
+
)
1199
+
1200
+
lexer := lexers.Get(filepath.Base(params.String.Filename))
1201
+
if lexer == nil {
1202
+
lexer = lexers.Fallback
1203
+
}
1204
+
1205
+
iterator, err := lexer.Tokenise(nil, c)
1206
+
if err != nil {
1207
+
return fmt.Errorf("chroma tokenize: %w", err)
1208
+
}
1209
+
1210
+
var code bytes.Buffer
1211
+
err = formatter.Format(&code, style, iterator)
1212
+
if err != nil {
1213
+
return fmt.Errorf("chroma format: %w", err)
1214
+
}
1215
+
1216
+
params.String.Contents = code.String()
1217
+
return p.execute("strings/string", w, params)
1143
1218
}
1144
1219
1145
1220
func (p *Pages) Static() http.Handler {
+25
-16
appview/pages/templates/layouts/topbar.html
+25
-16
appview/pages/templates/layouts/topbar.html
···
6
6
tangled<sub>alpha</sub>
7
7
</a>
8
8
</div>
9
-
<div class="hidden md:flex gap-4 items-center">
10
-
<a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center">
11
-
{{ i "message-circle" "size-4" }} discord
12
-
</a>
13
9
14
-
<a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center">
15
-
{{ i "hash" "size-4" }} irc
16
-
</a>
17
-
18
-
<a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center">
19
-
{{ i "code" "size-4" }} source
20
-
</a>
21
-
</div>
22
-
<div id="right-items" class="flex items-center gap-4">
10
+
<div id="right-items" class="flex items-center gap-2">
23
11
{{ with .LoggedInUser }}
24
-
<a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white">
25
-
{{ i "plus" "w-4 h-4" }}
26
-
</a>
12
+
{{ block "newButton" . }} {{ end }}
27
13
{{ block "dropDown" . }} {{ end }}
28
14
{{ else }}
29
15
<a href="/login">login</a>
16
+
<span class="text-gray-500 dark:text-gray-400">or</span>
17
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
18
+
join now {{ i "arrow-right" "size-4" }}
19
+
</a>
30
20
{{ end }}
31
21
</div>
32
22
</div>
33
23
</nav>
34
24
{{ end }}
35
25
26
+
{{ define "newButton" }}
27
+
<details class="relative inline-block text-left">
28
+
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
29
+
{{ i "plus" "w-4 h-4" }} new
30
+
</summary>
31
+
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
32
+
<a href="/repo/new" class="flex items-center gap-2">
33
+
{{ i "book-plus" "w-4 h-4" }}
34
+
new repository
35
+
</a>
36
+
<a href="/strings/new" class="flex items-center gap-2">
37
+
{{ i "line-squiggle" "w-4 h-4" }}
38
+
new string
39
+
</a>
40
+
</div>
41
+
</details>
42
+
{{ end }}
43
+
36
44
{{ define "dropDown" }}
37
45
<details class="relative inline-block text-left">
38
46
<summary
···
46
54
>
47
55
<a href="/{{ $user }}">profile</a>
48
56
<a href="/{{ $user }}?tab=repos">repositories</a>
57
+
<a href="/strings/{{ $user }}">strings</a>
49
58
<a href="/knots">knots</a>
50
59
<a href="/spindles">spindles</a>
51
60
<a href="/settings">settings</a>
+3
-1
appview/pages/templates/legal/privacy.html
+3
-1
appview/pages/templates/legal/privacy.html
···
1
1
{{ define "title" }} privacy policy {{ end }}
2
2
{{ define "content" }}
3
3
<div class="max-w-4xl mx-auto px-4 py-8">
4
-
<div class="prose prose-gray dark:prose-invert max-w-none">
4
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
5
+
<div class="prose prose-gray dark:prose-invert max-w-none">
5
6
<h1>Privacy Policy</h1>
6
7
7
8
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
···
125
126
126
127
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
127
128
<p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p>
129
+
</div>
128
130
</div>
129
131
</div>
130
132
</div>
+3
-1
appview/pages/templates/legal/terms.html
+3
-1
appview/pages/templates/legal/terms.html
···
2
2
3
3
{{ define "content" }}
4
4
<div class="max-w-4xl mx-auto px-4 py-8">
5
-
<div class="prose prose-gray dark:prose-invert max-w-none">
5
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
+
<div class="prose prose-gray dark:prose-invert max-w-none">
6
7
<h1>Terms of Service</h1>
7
8
8
9
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
···
63
64
64
65
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
65
66
<p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p>
67
+
</div>
66
68
</div>
67
69
</div>
68
70
</div>
+5
-1
appview/pages/templates/repo/pipelines/workflow.html
+5
-1
appview/pages/templates/repo/pipelines/workflow.html
···
19
19
20
20
{{ define "sidebar" }}
21
21
{{ $active := .Workflow }}
22
+
23
+
{{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }}
24
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }}
25
+
22
26
{{ with .Pipeline }}
23
27
{{ $id := .Id }}
24
28
<div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
25
29
{{ range $name, $all := .Statuses }}
26
30
<a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
27
31
<div
28
-
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}">
32
+
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
29
33
{{ $lastStatus := $all.Latest }}
30
34
{{ $kind := $lastStatus.Status.String }}
31
35
+27
-22
appview/pages/templates/repo/settings/pipelines.html
+27
-22
appview/pages/templates/repo/settings/pipelines.html
···
20
20
<div class="col-span-1 md:col-span-2">
21
21
<h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2>
22
22
<p class="text-gray-500 dark:text-gray-400">
23
-
Choose a spindle to execute your workflows on. Spindles can be
24
-
selfhosted,
23
+
Choose a spindle to execute your workflows on. Only repository owners
24
+
can configure spindles. Spindles can be selfhosted,
25
25
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
26
26
click to learn more.
27
27
</a>
28
28
</p>
29
29
</div>
30
-
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
31
-
<select
32
-
id="spindle"
33
-
name="spindle"
34
-
required
35
-
class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
36
-
{{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
37
-
<option value="" disabled selected >
38
-
Choose a spindle
39
-
</option>
40
-
{{ range $.Spindles }}
41
-
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
42
-
{{ . }}
30
+
{{ if not $.RepoInfo.Roles.IsOwner }}
31
+
<div class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
32
+
{{ or $.CurrentSpindle "No spindle configured" }}
33
+
</div>
34
+
{{ else }}
35
+
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
36
+
<select
37
+
id="spindle"
38
+
name="spindle"
39
+
required
40
+
class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
41
+
<option value="" disabled>
42
+
Choose a spindle
43
43
</option>
44
-
{{ end }}
45
-
</select>
46
-
<button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
47
-
{{ i "check" "size-4" }}
48
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
49
-
</button>
50
-
</form>
44
+
{{ range $.Spindles }}
45
+
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
46
+
{{ . }}
47
+
</option>
48
+
{{ end }}
49
+
</select>
50
+
<button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
51
+
{{ i "check" "size-4" }}
52
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
53
+
</button>
54
+
</form>
55
+
{{ end }}
51
56
</div>
52
57
{{ end }}
53
58
-168
appview/pages/templates/repo/settings.html
-168
appview/pages/templates/repo/settings.html
···
1
-
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
-
3
-
{{ define "repoContent" }}
4
-
{{ template "collaboratorSettings" . }}
5
-
{{ template "branchSettings" . }}
6
-
{{ template "dangerZone" . }}
7
-
{{ template "spindleSelector" . }}
8
-
{{ template "spindleSecrets" . }}
9
-
{{ end }}
10
-
11
-
{{ define "collaboratorSettings" }}
12
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
13
-
Collaborators
14
-
</header>
15
-
16
-
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
17
-
{{ range .Collaborators }}
18
-
<div id="collaborator" class="mb-2">
19
-
<a
20
-
href="/{{ didOrHandle .Did .Handle }}"
21
-
class="no-underline hover:underline text-black dark:text-white"
22
-
>
23
-
{{ didOrHandle .Did .Handle }}
24
-
</a>
25
-
<div>
26
-
<span class="text-sm text-gray-500 dark:text-gray-400">
27
-
{{ .Role }}
28
-
</span>
29
-
</div>
30
-
</div>
31
-
{{ end }}
32
-
</div>
33
-
34
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
35
-
<form
36
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
37
-
class="group"
38
-
>
39
-
<label for="collaborator" class="dark:text-white">
40
-
add collaborator
41
-
</label>
42
-
<input
43
-
type="text"
44
-
id="collaborator"
45
-
name="collaborator"
46
-
required
47
-
class="dark:bg-gray-700 dark:text-white"
48
-
placeholder="enter did or handle">
49
-
<button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text">
50
-
<span>add</span>
51
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
52
-
</button>
53
-
</form>
54
-
{{ end }}
55
-
{{ end }}
56
-
57
-
{{ define "dangerZone" }}
58
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
59
-
<form
60
-
hx-confirm="Are you sure you want to delete this repository?"
61
-
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
62
-
class="mt-6"
63
-
hx-indicator="#delete-repo-spinner">
64
-
<label for="branch">delete repository</label>
65
-
<button class="btn my-2 flex items-center" type="text">
66
-
<span>delete</span>
67
-
<span id="delete-repo-spinner" class="group">
68
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
69
-
</span>
70
-
</button>
71
-
<span>
72
-
Deleting a repository is irreversible and permanent.
73
-
</span>
74
-
</form>
75
-
{{ end }}
76
-
{{ end }}
77
-
78
-
{{ define "branchSettings" }}
79
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group">
80
-
<label for="branch">default branch</label>
81
-
<div class="flex gap-2 items-center">
82
-
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
83
-
<option value="" disabled selected >
84
-
Choose a default branch
85
-
</option>
86
-
{{ range .Branches }}
87
-
<option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} >
88
-
{{ .Name }}
89
-
</option>
90
-
{{ end }}
91
-
</select>
92
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
93
-
<span>save</span>
94
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
95
-
</button>
96
-
</div>
97
-
</form>
98
-
{{ end }}
99
-
100
-
{{ define "spindleSelector" }}
101
-
{{ if .RepoInfo.Roles.IsOwner }}
102
-
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" >
103
-
<label for="spindle">spindle</label>
104
-
<div class="flex gap-2 items-center">
105
-
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
106
-
<option value="" selected >
107
-
None
108
-
</option>
109
-
{{ range .Spindles }}
110
-
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
111
-
{{ . }}
112
-
</option>
113
-
{{ end }}
114
-
</select>
115
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
116
-
<span>save</span>
117
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
118
-
</button>
119
-
</div>
120
-
</form>
121
-
{{ end }}
122
-
{{ end }}
123
-
124
-
{{ define "spindleSecrets" }}
125
-
{{ if $.CurrentSpindle }}
126
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
127
-
Secrets
128
-
</header>
129
-
130
-
<div id="secret-list" class="flex flex-col gap-2 mb-2">
131
-
{{ range $idx, $secret := .Secrets }}
132
-
{{ with $secret }}
133
-
<div id="secret-{{$idx}}" class="mb-2">
134
-
{{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }}
135
-
</div>
136
-
{{ end }}
137
-
{{ end }}
138
-
</div>
139
-
<form
140
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
141
-
class="mt-6"
142
-
hx-indicator="#add-secret-spinner">
143
-
<label for="key">secret key</label>
144
-
<input
145
-
type="text"
146
-
id="key"
147
-
name="key"
148
-
required
149
-
class="dark:bg-gray-700 dark:text-white"
150
-
placeholder="SECRET_KEY" />
151
-
<label for="value">secret value</label>
152
-
<input
153
-
type="text"
154
-
id="value"
155
-
name="value"
156
-
required
157
-
class="dark:bg-gray-700 dark:text-white"
158
-
placeholder="SECRET VALUE" />
159
-
160
-
<button class="btn my-2 flex items-center" type="text">
161
-
<span>add</span>
162
-
<span id="add-secret-spinner" class="group">
163
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
164
-
</span>
165
-
</button>
166
-
</form>
167
-
{{ end }}
168
-
{{ end }}
+3
-2
appview/pages/templates/repo/tree.html
+3
-2
appview/pages/templates/repo/tree.html
···
61
61
62
62
{{ if .IsFile }}
63
63
{{ $icon = "file" }}
64
-
{{ $iconStyle = "size-4" }}
64
+
{{ $iconStyle = "flex-shrink-0 size-4" }}
65
65
{{ end }}
66
66
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
67
<div class="flex items-center gap-2">
68
-
{{ i $icon $iconStyle }}{{ .Name }}
68
+
{{ i $icon $iconStyle }}
69
+
<span class="truncate">{{ .Name }}</span>
69
70
</div>
70
71
</a>
71
72
</div>
+57
appview/pages/templates/strings/dashboard.html
+57
appview/pages/templates/strings/dashboard.html
···
1
+
{{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
+
<meta property="og:type" content="profile" />
6
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
7
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
+
{{ end }}
9
+
10
+
11
+
{{ define "content" }}
12
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
13
+
<div class="md:col-span-3 order-1 md:order-1">
14
+
{{ template "user/fragments/profileCard" .Card }}
15
+
</div>
16
+
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
17
+
{{ block "allStrings" . }}{{ end }}
18
+
</div>
19
+
</div>
20
+
{{ end }}
21
+
22
+
{{ define "allStrings" }}
23
+
<p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p>
24
+
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
25
+
{{ range .Strings }}
26
+
{{ template "singleString" (list $ .) }}
27
+
{{ else }}
28
+
<p class="px-6 dark:text-white">This user does not have any strings yet.</p>
29
+
{{ end }}
30
+
</div>
31
+
{{ end }}
32
+
33
+
{{ define "singleString" }}
34
+
{{ $root := index . 0 }}
35
+
{{ $s := index . 1 }}
36
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
37
+
<div class="font-medium dark:text-white flex gap-2 items-center">
38
+
<a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
39
+
</div>
40
+
{{ with $s.Description }}
41
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
42
+
{{ . }}
43
+
</div>
44
+
{{ end }}
45
+
46
+
{{ $stat := $s.Stats }}
47
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto">
48
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
49
+
<span class="select-none [&:before]:content-['ยท']"></span>
50
+
{{ with $s.Edited }}
51
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
52
+
{{ else }}
53
+
{{ template "repo/fragments/shortTimeAgo" $s.Created }}
54
+
{{ end }}
55
+
</div>
56
+
</div>
57
+
{{ end }}
+89
appview/pages/templates/strings/fragments/form.html
+89
appview/pages/templates/strings/fragments/form.html
···
1
+
{{ define "strings/fragments/form" }}
2
+
<form
3
+
{{ if eq .Action "new" }}
4
+
hx-post="/strings/new"
5
+
{{ else }}
6
+
hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit"
7
+
{{ end }}
8
+
hx-indicator="#new-button"
9
+
class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded"
10
+
hx-swap="none">
11
+
<div class="flex flex-col md:flex-row md:items-center gap-2">
12
+
<input
13
+
type="text"
14
+
id="filename"
15
+
name="filename"
16
+
placeholder="Filename with extension"
17
+
required
18
+
value="{{ .String.Filename }}"
19
+
class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
20
+
>
21
+
<input
22
+
type="text"
23
+
id="description"
24
+
name="description"
25
+
value="{{ .String.Description }}"
26
+
placeholder="Description ..."
27
+
class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
28
+
>
29
+
</div>
30
+
<textarea
31
+
name="content"
32
+
id="content-textarea"
33
+
wrap="off"
34
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
35
+
rows="20"
36
+
placeholder="Paste your string here!"
37
+
required>{{ .String.Contents }}</textarea>
38
+
<div class="flex justify-between items-center">
39
+
<div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400">
40
+
<span id="line-count">0 lines</span>
41
+
<span class="select-none px-1 [&:before]:content-['ยท']"></span>
42
+
<span id="byte-count">0 bytes</span>
43
+
</div>
44
+
<div id="actions" class="flex gap-2 items-center">
45
+
{{ if eq .Action "edit" }}
46
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 "
47
+
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}">
48
+
{{ i "x" "size-4" }}
49
+
<span class="hidden md:inline">cancel</span>
50
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
51
+
</a>
52
+
{{ end }}
53
+
<button
54
+
type="submit"
55
+
id="new-button"
56
+
class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group"
57
+
>
58
+
<span class="inline-flex items-center gap-2">
59
+
{{ i "arrow-up" "w-4 h-4" }}
60
+
publish
61
+
</span>
62
+
<span class="pl-2 hidden group-[.htmx-request]:inline">
63
+
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
64
+
</span>
65
+
</button>
66
+
</div>
67
+
</div>
68
+
<script>
69
+
(function() {
70
+
const textarea = document.getElementById('content-textarea');
71
+
const lineCount = document.getElementById('line-count');
72
+
const byteCount = document.getElementById('byte-count');
73
+
function updateStats() {
74
+
const content = textarea.value;
75
+
const lines = content === '' ? 0 : content.split('\n').length;
76
+
const bytes = new TextEncoder().encode(content).length;
77
+
lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`;
78
+
byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`;
79
+
}
80
+
textarea.addEventListener('input', updateStats);
81
+
textarea.addEventListener('paste', () => {
82
+
setTimeout(updateStats, 0);
83
+
});
84
+
updateStats();
85
+
})();
86
+
</script>
87
+
<div id="error" class="error dark:text-red-400"></div>
88
+
</form>
89
+
{{ end }}
+17
appview/pages/templates/strings/put.html
+17
appview/pages/templates/strings/put.html
···
1
+
{{ define "title" }}publish a new string{{ end }}
2
+
3
+
{{ define "topbar" }}
4
+
{{ template "layouts/topbar" $ }}
5
+
{{ end }}
6
+
7
+
{{ define "content" }}
8
+
<div class="px-6 py-2 mb-4">
9
+
{{ if eq .Action "new" }}
10
+
<p class="text-xl font-bold dark:text-white">Create a new string</p>
11
+
<p class="">Store and share code snippets with ease.</p>
12
+
{{ else }}
13
+
<p class="text-xl font-bold dark:text-white">Edit string</p>
14
+
{{ end }}
15
+
</div>
16
+
{{ template "strings/fragments/form" . }}
17
+
{{ end }}
+85
appview/pages/templates/strings/string.html
+85
appview/pages/templates/strings/string.html
···
1
+
{{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
5
+
<meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" />
6
+
<meta property="og:type" content="object" />
7
+
<meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
8
+
<meta property="og:description" content="{{ .String.Description }}" />
9
+
{{ end }}
10
+
11
+
{{ define "topbar" }}
12
+
{{ template "layouts/topbar" $ }}
13
+
{{ end }}
14
+
15
+
{{ define "content" }}
16
+
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
17
+
<section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
18
+
<div class="text-lg flex items-center justify-between">
19
+
<div>
20
+
<a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a>
21
+
<span class="select-none">/</span>
22
+
<a href="/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
23
+
</div>
24
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
25
+
<div class="flex gap-2 text-base">
26
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
27
+
hx-boost="true"
28
+
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
29
+
{{ i "pencil" "size-4" }}
30
+
<span class="hidden md:inline">edit</span>
31
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
32
+
</a>
33
+
<button
34
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2"
35
+
title="Delete string"
36
+
hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/"
37
+
hx-swap="none"
38
+
hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?"
39
+
>
40
+
{{ i "trash-2" "size-4" }}
41
+
<span class="hidden md:inline">delete</span>
42
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
43
+
</button>
44
+
</div>
45
+
{{ end }}
46
+
</div>
47
+
<span class="flex items-center">
48
+
{{ with .String.Description }}
49
+
{{ . }}
50
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
51
+
{{ end }}
52
+
53
+
{{ with .String.Edited }}
54
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
55
+
{{ else }}
56
+
{{ template "repo/fragments/shortTimeAgo" .String.Created }}
57
+
{{ end }}
58
+
</span>
59
+
</section>
60
+
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
61
+
<div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
62
+
<span>{{ .String.Filename }}</span>
63
+
<div>
64
+
<span>{{ .Stats.LineCount }} lines</span>
65
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
66
+
<span>{{ byteFmt .Stats.ByteCount }}</span>
67
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
68
+
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a>
69
+
{{ if .RenderToggle }}
70
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
71
+
<a href="?code={{ .ShowRendered }}" hx-boost="true">
72
+
view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}
73
+
</a>
74
+
{{ end }}
75
+
</div>
76
+
</div>
77
+
<div class="overflow-auto relative">
78
+
{{ if .ShowRendered }}
79
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
80
+
{{ else }}
81
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
82
+
{{ end }}
83
+
</div>
84
+
</section>
85
+
{{ end }}
+2
-2
appview/pages/templates/timeline.html
+2
-2
appview/pages/templates/timeline.html
···
34
34
</p>
35
35
36
36
<div class="flex gap-6 items-center">
37
-
<a href="/login" class="no-underline hover:no-underline ">
38
-
<button class="btn flex gap-2 px-4 items-center">
37
+
<a href="/signup" class="no-underline hover:no-underline ">
38
+
<button class="btn-create flex gap-2 px-4 items-center">
39
39
join now {{ i "arrow-right" "size-4" }}
40
40
</button>
41
41
</a>
+5
-5
appview/pages/templates/user/completeSignup.html
+5
-5
appview/pages/templates/user/completeSignup.html
···
38
38
tightly-knit social coding.
39
39
</h2>
40
40
<form
41
-
class="mt-4 max-w-sm mx-auto"
41
+
class="mt-4 max-w-sm mx-auto flex flex-col gap-4"
42
42
hx-post="/signup/complete"
43
43
hx-swap="none"
44
44
hx-disabled-elt="#complete-signup-button"
···
58
58
</span>
59
59
</div>
60
60
61
-
<div class="flex flex-col mt-4">
62
-
<label for="username">desired username</label>
61
+
<div class="flex flex-col">
62
+
<label for="username">username</label>
63
63
<input
64
64
type="text"
65
65
id="username"
···
73
73
</span>
74
74
</div>
75
75
76
-
<div class="flex flex-col mt-4">
76
+
<div class="flex flex-col">
77
77
<label for="password">password</label>
78
78
<input
79
79
type="password"
···
88
88
</div>
89
89
90
90
<button
91
-
class="btn-create w-full my-2 mt-6"
91
+
class="btn-create w-full my-2 mt-6 text-base"
92
92
type="submit"
93
93
id="complete-signup-button"
94
94
tabindex="4"
+1
-3
appview/pages/templates/user/fragments/profileCard.html
+1
-3
appview/pages/templates/user/fragments/profileCard.html
···
2
2
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
3
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
4
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
5
-
{{ if .AvatarUri }}
6
5
<div class="w-3/4 aspect-square relative">
7
-
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" />
6
+
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" />
8
7
</div>
9
-
{{ end }}
10
8
</div>
11
9
<div class="col-span-2">
12
10
<p title="{{ didOrHandle .UserDid .UserHandle }}"
+11
-79
appview/pages/templates/user/login.html
+11
-79
appview/pages/templates/user/login.html
···
3
3
<html lang="en" class="dark:bg-gray-900">
4
4
<head>
5
5
<meta charset="UTF-8" />
6
-
<meta
7
-
name="viewport"
8
-
content="width=device-width, initial-scale=1.0"
9
-
/>
10
-
<meta
11
-
property="og:title"
12
-
content="login ยท tangled"
13
-
/>
14
-
<meta
15
-
property="og:url"
16
-
content="https://tangled.sh/login"
17
-
/>
18
-
<meta
19
-
property="og:description"
20
-
content="login to or sign up for tangled"
21
-
/>
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+
<meta property="og:title" content="login ยท tangled" />
8
+
<meta property="og:url" content="https://tangled.sh/login" />
9
+
<meta property="og:description" content="login to for tangled" />
22
10
<script src="/static/htmx.min.js"></script>
23
-
<link
24
-
rel="stylesheet"
25
-
href="/static/tw.css?{{ cssContentHash }}"
26
-
type="text/css"
27
-
/>
28
-
<title>login or sign up · tangled</title>
11
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
+
<title>login · tangled</title>
29
13
</head>
30
14
<body class="flex items-center justify-center min-h-screen">
31
15
<main class="max-w-md px-6 -mt-4">
32
-
<h1
33
-
class="text-center text-2xl font-semibold italic dark:text-white"
34
-
>
16
+
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >
35
17
tangled
36
18
</h1>
37
19
<h2 class="text-center text-xl italic dark:text-white">
···
51
33
name="handle"
52
34
tabindex="1"
53
35
required
54
-
placeholder="foo.tngl.sh"
36
+
placeholder="akshay.tngl.sh"
55
37
/>
56
38
<span class="text-sm text-gray-500 mt-1">
57
39
Use your <a href="https://atproto.com">ATProto</a>
···
61
43
</div>
62
44
63
45
<button
64
-
class="btn w-full my-2 mt-6"
46
+
class="btn w-full my-2 mt-6 text-base "
65
47
type="submit"
66
48
id="login-button"
67
49
tabindex="3"
···
69
51
<span>login</span>
70
52
</button>
71
53
</form>
72
-
<hr class="my-4">
73
-
<p class="text-sm text-gray-500 mt-4">
74
-
Alternatively, you may create an account on Tangled below. You will
75
-
get a <code>user.tngl.sh</code> handle.
54
+
<p class="text-sm text-gray-500">
55
+
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
76
56
</p>
77
57
78
-
<details class="group">
79
-
80
-
<summary
81
-
class="btn cursor-pointer w-full mt-4 flex items-center justify-center gap-2"
82
-
>
83
-
create an account
84
-
85
-
<div class="group-open:hidden flex">{{ i "arrow-right" "w-4 h-4" }}</div>
86
-
<div class="hidden group-open:flex">{{ i "arrow-down" "w-4 h-4" }}</div>
87
-
</summary>
88
-
<form
89
-
class="mt-4 max-w-sm mx-auto"
90
-
hx-post="/signup"
91
-
hx-swap="none"
92
-
hx-disabled-elt="#signup-button"
93
-
>
94
-
<div class="flex flex-col mt-2">
95
-
<label for="email">email</label>
96
-
<input
97
-
type="email"
98
-
id="email"
99
-
name="email"
100
-
tabindex="4"
101
-
required
102
-
placeholder="jason@bourne.co"
103
-
/>
104
-
</div>
105
-
<span class="text-sm text-gray-500 mt-1">
106
-
You will receive an email with a code. Enter that, along with your
107
-
desired username and password in the next page to complete your registration.
108
-
</span>
109
-
<button
110
-
class="btn w-full my-2 mt-6"
111
-
type="submit"
112
-
id="signup-button"
113
-
tabindex="7"
114
-
>
115
-
<span>sign up</span>
116
-
</button>
117
-
</form>
118
-
</details>
119
-
<p class="text-sm text-gray-500 mt-6">
120
-
Join our <a href="https://chat.tangled.sh">Discord</a> or
121
-
IRC channel:
122
-
<a href="https://web.libera.chat/#tangled"
123
-
><code>#tangled</code> on Libera Chat</a
124
-
>.
125
-
</p>
126
58
<p id="login-msg" class="error w-full"></p>
127
59
</main>
128
60
</body>
+53
appview/pages/templates/user/signup.html
+53
appview/pages/templates/user/signup.html
···
1
+
{{ define "user/signup" }}
2
+
<!doctype html>
3
+
<html lang="en" class="dark:bg-gray-900">
4
+
<head>
5
+
<meta charset="UTF-8" />
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+
<meta property="og:title" content="signup ยท tangled" />
8
+
<meta property="og:url" content="https://tangled.sh/signup" />
9
+
<meta property="og:description" content="sign up for tangled" />
10
+
<script src="/static/htmx.min.js"></script>
11
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
+
<title>sign up · tangled</title>
13
+
</head>
14
+
<body class="flex items-center justify-center min-h-screen">
15
+
<main class="max-w-md px-6 -mt-4">
16
+
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1>
17
+
<h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2>
18
+
<form
19
+
class="mt-4 max-w-sm mx-auto"
20
+
hx-post="/signup"
21
+
hx-swap="none"
22
+
hx-disabled-elt="#signup-button"
23
+
>
24
+
<div class="flex flex-col mt-2">
25
+
<label for="email">email</label>
26
+
<input
27
+
type="email"
28
+
id="email"
29
+
name="email"
30
+
tabindex="4"
31
+
required
32
+
placeholder="jason@bourne.co"
33
+
/>
34
+
</div>
35
+
<span class="text-sm text-gray-500 mt-1">
36
+
You will receive an email with an invite code. Enter your
37
+
invite code, desired username, and password in the next
38
+
page to complete your registration.
39
+
</span>
40
+
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
41
+
<span>join now</span>
42
+
</button>
43
+
</form>
44
+
<p class="text-sm text-gray-500">
45
+
Already have an account? <a href="/login" class="underline">Login to Tangled</a>.
46
+
</p>
47
+
48
+
<p id="signup-msg" class="error w-full"></p>
49
+
</main>
50
+
</body>
51
+
</html>
52
+
{{ end }}
53
+
+5
-2
appview/repo/repo.go
+5
-2
appview/repo/repo.go
···
742
742
return
743
743
}
744
744
745
+
// remove a single leading `@`, to make @handle work with ResolveIdent
746
+
collaborator = strings.TrimPrefix(collaborator, "@")
747
+
745
748
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
746
749
if err != nil {
747
750
fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
···
1225
1228
f, err := rp.repoResolver.Resolve(r)
1226
1229
user := rp.oauth.GetUser(r)
1227
1230
1228
-
// all spindles that this user is a member of
1229
-
spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1231
+
// all spindles that the repo owner is a member of
1232
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1230
1233
if err != nil {
1231
1234
log.Println("failed to fetch spindles", err)
1232
1235
return
+57
-50
appview/signup/signup.go
+57
-50
appview/signup/signup.go
···
104
104
105
105
func (s *Signup) Router() http.Handler {
106
106
r := chi.NewRouter()
107
+
r.Get("/", s.signup)
107
108
r.Post("/", s.signup)
108
109
r.Get("/complete", s.complete)
109
110
r.Post("/complete", s.complete)
···
112
113
}
113
114
114
115
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
115
-
if s.cf == nil {
116
-
http.Error(w, "signup is disabled", http.StatusFailedDependency)
117
-
}
118
-
emailId := r.FormValue("email")
116
+
switch r.Method {
117
+
case http.MethodGet:
118
+
s.pages.Signup(w)
119
+
case http.MethodPost:
120
+
if s.cf == nil {
121
+
http.Error(w, "signup is disabled", http.StatusFailedDependency)
122
+
}
123
+
emailId := r.FormValue("email")
119
124
120
-
if !email.IsValidEmail(emailId) {
121
-
s.pages.Notice(w, "login-msg", "Invalid email address.")
122
-
return
123
-
}
125
+
noticeId := "signup-msg"
126
+
if !email.IsValidEmail(emailId) {
127
+
s.pages.Notice(w, noticeId, "Invalid email address.")
128
+
return
129
+
}
124
130
125
-
exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
126
-
if err != nil {
127
-
s.l.Error("failed to check email existence", "error", err)
128
-
s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.")
129
-
return
130
-
}
131
-
if exists {
132
-
s.pages.Notice(w, "login-msg", "Email already exists.")
133
-
return
134
-
}
131
+
exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
132
+
if err != nil {
133
+
s.l.Error("failed to check email existence", "error", err)
134
+
s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.")
135
+
return
136
+
}
137
+
if exists {
138
+
s.pages.Notice(w, noticeId, "Email already exists.")
139
+
return
140
+
}
135
141
136
-
code, err := s.inviteCodeRequest()
137
-
if err != nil {
138
-
s.l.Error("failed to create invite code", "error", err)
139
-
s.pages.Notice(w, "login-msg", "Failed to create invite code.")
140
-
return
141
-
}
142
+
code, err := s.inviteCodeRequest()
143
+
if err != nil {
144
+
s.l.Error("failed to create invite code", "error", err)
145
+
s.pages.Notice(w, noticeId, "Failed to create invite code.")
146
+
return
147
+
}
142
148
143
-
em := email.Email{
144
-
APIKey: s.config.Resend.ApiKey,
145
-
From: s.config.Resend.SentFrom,
146
-
To: emailId,
147
-
Subject: "Verify your Tangled account",
148
-
Text: `Copy and paste this code below to verify your account on Tangled.
149
+
em := email.Email{
150
+
APIKey: s.config.Resend.ApiKey,
151
+
From: s.config.Resend.SentFrom,
152
+
To: emailId,
153
+
Subject: "Verify your Tangled account",
154
+
Text: `Copy and paste this code below to verify your account on Tangled.
149
155
` + code,
150
-
Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
156
+
Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
151
157
<p><code>` + code + `</code></p>`,
152
-
}
158
+
}
159
+
160
+
err = email.SendEmail(em)
161
+
if err != nil {
162
+
s.l.Error("failed to send email", "error", err)
163
+
s.pages.Notice(w, noticeId, "Failed to send email.")
164
+
return
165
+
}
166
+
err = db.AddInflightSignup(s.db, db.InflightSignup{
167
+
Email: emailId,
168
+
InviteCode: code,
169
+
})
170
+
if err != nil {
171
+
s.l.Error("failed to add inflight signup", "error", err)
172
+
s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.")
173
+
return
174
+
}
153
175
154
-
err = email.SendEmail(em)
155
-
if err != nil {
156
-
s.l.Error("failed to send email", "error", err)
157
-
s.pages.Notice(w, "login-msg", "Failed to send email.")
158
-
return
176
+
s.pages.HxRedirect(w, "/signup/complete")
159
177
}
160
-
err = db.AddInflightSignup(s.db, db.InflightSignup{
161
-
Email: emailId,
162
-
InviteCode: code,
163
-
})
164
-
if err != nil {
165
-
s.l.Error("failed to add inflight signup", "error", err)
166
-
s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.")
167
-
return
168
-
}
169
-
170
-
s.pages.HxRedirect(w, "/signup/complete")
171
178
}
172
179
173
180
func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
174
181
switch r.Method {
175
182
case http.MethodGet:
176
-
s.pages.CompleteSignup(w, pages.SignupParams{})
183
+
s.pages.CompleteSignup(w)
177
184
case http.MethodPost:
178
185
username := r.FormValue("username")
179
186
password := r.FormValue("password")
···
212
219
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
213
220
Type: "TXT",
214
221
Name: "_atproto." + username,
215
-
Content: "did=" + did,
222
+
Content: fmt.Sprintf(`"did=%s"`, did),
216
223
TTL: 6400,
217
224
Proxied: false,
218
225
})
+4
-4
appview/spindles/spindles.go
+4
-4
appview/spindles/spindles.go
···
619
619
620
620
if string(spindles[0].Owner) != user.Did {
621
621
l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
622
-
s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
622
+
s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.")
623
623
return
624
624
}
625
625
626
626
member := r.FormValue("member")
627
627
if member == "" {
628
628
l.Error("empty member")
629
-
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
629
+
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
630
630
return
631
631
}
632
632
l = l.With("member", member)
···
634
634
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
635
635
if err != nil {
636
636
l.Error("failed to resolve member identity to handle", "err", err)
637
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
637
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
638
638
return
639
639
}
640
640
if memberId.Handle.IsInvalidHandle() {
641
641
l.Error("failed to resolve member identity to handle")
642
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
642
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
643
643
return
644
644
}
645
645
-16
appview/state/profile.go
-16
appview/state/profile.go
···
1
1
package state
2
2
3
3
import (
4
-
"crypto/hmac"
5
-
"crypto/sha256"
6
-
"encoding/hex"
7
4
"fmt"
8
5
"log"
9
6
"net/http"
···
142
139
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
143
140
}
144
141
145
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
146
142
s.pages.ProfilePage(w, pages.ProfilePageParams{
147
143
LoggedInUser: loggedInUser,
148
144
Repos: pinnedRepos,
···
151
147
Card: pages.ProfileCard{
152
148
UserDid: ident.DID.String(),
153
149
UserHandle: ident.Handle.String(),
154
-
AvatarUri: profileAvatarUri,
155
150
Profile: profile,
156
151
FollowStatus: followStatus,
157
152
Followers: followers,
···
194
189
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
195
190
}
196
191
197
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
198
-
199
192
s.pages.ReposPage(w, pages.ReposPageParams{
200
193
LoggedInUser: loggedInUser,
201
194
Repos: repos,
···
203
196
Card: pages.ProfileCard{
204
197
UserDid: ident.DID.String(),
205
198
UserHandle: ident.Handle.String(),
206
-
AvatarUri: profileAvatarUri,
207
199
Profile: profile,
208
200
FollowStatus: followStatus,
209
201
Followers: followers,
210
202
Following: following,
211
203
},
212
204
})
213
-
}
214
-
215
-
func (s *State) GetAvatarUri(handle string) string {
216
-
secret := s.config.Avatar.SharedSecret
217
-
h := hmac.New(sha256.New, []byte(secret))
218
-
h.Write([]byte(handle))
219
-
signature := hex.EncodeToString(h.Sum(nil))
220
-
return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle)
221
205
}
222
206
223
207
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
+19
-3
appview/state/router.go
+19
-3
appview/state/router.go
···
17
17
"tangled.sh/tangled.sh/core/appview/signup"
18
18
"tangled.sh/tangled.sh/core/appview/spindles"
19
19
"tangled.sh/tangled.sh/core/appview/state/userutil"
20
+
avstrings "tangled.sh/tangled.sh/core/appview/strings"
20
21
"tangled.sh/tangled.sh/core/log"
21
22
)
22
23
···
67
68
func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
68
69
r := chi.NewRouter()
69
70
70
-
// strip @ from user
71
-
r.Use(middleware.StripLeadingAt)
72
-
73
71
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
74
72
r.Get("/", s.Profile)
75
73
···
136
134
})
137
135
138
136
r.Mount("/settings", s.SettingsRouter())
137
+
r.Mount("/strings", s.StringsRouter(mw))
139
138
r.Mount("/knots", s.KnotsRouter(mw))
140
139
r.Mount("/spindles", s.SpindlesRouter())
141
140
r.Mount("/signup", s.SignupRouter())
···
199
198
}
200
199
201
200
return knots.Router(mw)
201
+
}
202
+
203
+
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
204
+
logger := log.New("strings")
205
+
206
+
strs := &avstrings.Strings{
207
+
Db: s.db,
208
+
OAuth: s.oauth,
209
+
Pages: s.pages,
210
+
Config: s.config,
211
+
Enforcer: s.enforcer,
212
+
IdResolver: s.idResolver,
213
+
Knotstream: s.knotstream,
214
+
Logger: logger,
215
+
}
216
+
217
+
return strs.Router(mw)
202
218
}
203
219
204
220
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
+1
appview/state/state.go
+1
appview/state/state.go
+454
appview/strings/strings.go
+454
appview/strings/strings.go
···
1
+
package strings
2
+
3
+
import (
4
+
"fmt"
5
+
"log/slog"
6
+
"net/http"
7
+
"path"
8
+
"slices"
9
+
"strconv"
10
+
"strings"
11
+
"time"
12
+
13
+
"tangled.sh/tangled.sh/core/api/tangled"
14
+
"tangled.sh/tangled.sh/core/appview/config"
15
+
"tangled.sh/tangled.sh/core/appview/db"
16
+
"tangled.sh/tangled.sh/core/appview/middleware"
17
+
"tangled.sh/tangled.sh/core/appview/oauth"
18
+
"tangled.sh/tangled.sh/core/appview/pages"
19
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
20
+
"tangled.sh/tangled.sh/core/eventconsumer"
21
+
"tangled.sh/tangled.sh/core/idresolver"
22
+
"tangled.sh/tangled.sh/core/rbac"
23
+
"tangled.sh/tangled.sh/core/tid"
24
+
25
+
"github.com/bluesky-social/indigo/api/atproto"
26
+
"github.com/bluesky-social/indigo/atproto/identity"
27
+
"github.com/bluesky-social/indigo/atproto/syntax"
28
+
lexutil "github.com/bluesky-social/indigo/lex/util"
29
+
"github.com/go-chi/chi/v5"
30
+
)
31
+
32
+
type Strings struct {
33
+
Db *db.DB
34
+
OAuth *oauth.OAuth
35
+
Pages *pages.Pages
36
+
Config *config.Config
37
+
Enforcer *rbac.Enforcer
38
+
IdResolver *idresolver.Resolver
39
+
Logger *slog.Logger
40
+
Knotstream *eventconsumer.Consumer
41
+
}
42
+
43
+
func (s *Strings) Router(mw *middleware.Middleware) http.Handler {
44
+
r := chi.NewRouter()
45
+
46
+
r.
47
+
With(mw.ResolveIdent()).
48
+
Route("/{user}", func(r chi.Router) {
49
+
r.Get("/", s.dashboard)
50
+
51
+
r.Route("/{rkey}", func(r chi.Router) {
52
+
r.Get("/", s.contents)
53
+
r.Delete("/", s.delete)
54
+
r.Get("/raw", s.contents)
55
+
r.Get("/edit", s.edit)
56
+
r.Post("/edit", s.edit)
57
+
r.
58
+
With(middleware.AuthMiddleware(s.OAuth)).
59
+
Post("/comment", s.comment)
60
+
})
61
+
})
62
+
63
+
r.
64
+
With(middleware.AuthMiddleware(s.OAuth)).
65
+
Route("/new", func(r chi.Router) {
66
+
r.Get("/", s.create)
67
+
r.Post("/", s.create)
68
+
})
69
+
70
+
return r
71
+
}
72
+
73
+
func (s *Strings) contents(w http.ResponseWriter, r *http.Request) {
74
+
l := s.Logger.With("handler", "contents")
75
+
76
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
77
+
if !ok {
78
+
l.Error("malformed middleware")
79
+
w.WriteHeader(http.StatusInternalServerError)
80
+
return
81
+
}
82
+
l = l.With("did", id.DID, "handle", id.Handle)
83
+
84
+
rkey := chi.URLParam(r, "rkey")
85
+
if rkey == "" {
86
+
l.Error("malformed url, empty rkey")
87
+
w.WriteHeader(http.StatusBadRequest)
88
+
return
89
+
}
90
+
l = l.With("rkey", rkey)
91
+
92
+
strings, err := db.GetStrings(
93
+
s.Db,
94
+
db.FilterEq("did", id.DID),
95
+
db.FilterEq("rkey", rkey),
96
+
)
97
+
if err != nil {
98
+
l.Error("failed to fetch string", "err", err)
99
+
w.WriteHeader(http.StatusInternalServerError)
100
+
return
101
+
}
102
+
if len(strings) < 1 {
103
+
l.Error("string not found")
104
+
s.Pages.Error404(w)
105
+
return
106
+
}
107
+
if len(strings) != 1 {
108
+
l.Error("incorrect number of records returned", "len(strings)", len(strings))
109
+
w.WriteHeader(http.StatusInternalServerError)
110
+
return
111
+
}
112
+
string := strings[0]
113
+
114
+
if path.Base(r.URL.Path) == "raw" {
115
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
116
+
if string.Filename != "" {
117
+
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename))
118
+
}
119
+
w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents)))
120
+
121
+
_, err = w.Write([]byte(string.Contents))
122
+
if err != nil {
123
+
l.Error("failed to write raw response", "err", err)
124
+
}
125
+
return
126
+
}
127
+
128
+
var showRendered, renderToggle bool
129
+
if markup.GetFormat(string.Filename) == markup.FormatMarkdown {
130
+
renderToggle = true
131
+
showRendered = r.URL.Query().Get("code") != "true"
132
+
}
133
+
134
+
s.Pages.SingleString(w, pages.SingleStringParams{
135
+
LoggedInUser: s.OAuth.GetUser(r),
136
+
RenderToggle: renderToggle,
137
+
ShowRendered: showRendered,
138
+
String: string,
139
+
Stats: string.Stats(),
140
+
Owner: id,
141
+
})
142
+
}
143
+
144
+
func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
145
+
l := s.Logger.With("handler", "dashboard")
146
+
147
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
148
+
if !ok {
149
+
l.Error("malformed middleware")
150
+
w.WriteHeader(http.StatusInternalServerError)
151
+
return
152
+
}
153
+
l = l.With("did", id.DID, "handle", id.Handle)
154
+
155
+
all, err := db.GetStrings(
156
+
s.Db,
157
+
db.FilterEq("did", id.DID),
158
+
)
159
+
if err != nil {
160
+
l.Error("failed to fetch strings", "err", err)
161
+
w.WriteHeader(http.StatusInternalServerError)
162
+
return
163
+
}
164
+
165
+
slices.SortFunc(all, func(a, b db.String) int {
166
+
if a.Created.After(b.Created) {
167
+
return -1
168
+
} else {
169
+
return 1
170
+
}
171
+
})
172
+
173
+
profile, err := db.GetProfile(s.Db, id.DID.String())
174
+
if err != nil {
175
+
l.Error("failed to fetch user profile", "err", err)
176
+
w.WriteHeader(http.StatusInternalServerError)
177
+
return
178
+
}
179
+
loggedInUser := s.OAuth.GetUser(r)
180
+
followStatus := db.IsNotFollowing
181
+
if loggedInUser != nil {
182
+
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
183
+
}
184
+
185
+
followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String())
186
+
if err != nil {
187
+
l.Error("failed to get follow stats", "err", err)
188
+
}
189
+
190
+
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
191
+
LoggedInUser: s.OAuth.GetUser(r),
192
+
Card: pages.ProfileCard{
193
+
UserDid: id.DID.String(),
194
+
UserHandle: id.Handle.String(),
195
+
Profile: profile,
196
+
FollowStatus: followStatus,
197
+
Followers: followers,
198
+
Following: following,
199
+
},
200
+
Strings: all,
201
+
})
202
+
}
203
+
204
+
func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
205
+
l := s.Logger.With("handler", "edit")
206
+
207
+
user := s.OAuth.GetUser(r)
208
+
209
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
210
+
if !ok {
211
+
l.Error("malformed middleware")
212
+
w.WriteHeader(http.StatusInternalServerError)
213
+
return
214
+
}
215
+
l = l.With("did", id.DID, "handle", id.Handle)
216
+
217
+
rkey := chi.URLParam(r, "rkey")
218
+
if rkey == "" {
219
+
l.Error("malformed url, empty rkey")
220
+
w.WriteHeader(http.StatusBadRequest)
221
+
return
222
+
}
223
+
l = l.With("rkey", rkey)
224
+
225
+
// get the string currently being edited
226
+
all, err := db.GetStrings(
227
+
s.Db,
228
+
db.FilterEq("did", id.DID),
229
+
db.FilterEq("rkey", rkey),
230
+
)
231
+
if err != nil {
232
+
l.Error("failed to fetch string", "err", err)
233
+
w.WriteHeader(http.StatusInternalServerError)
234
+
return
235
+
}
236
+
if len(all) != 1 {
237
+
l.Error("incorrect number of records returned", "len(strings)", len(all))
238
+
w.WriteHeader(http.StatusInternalServerError)
239
+
return
240
+
}
241
+
first := all[0]
242
+
243
+
// verify that the logged in user owns this string
244
+
if user.Did != id.DID.String() {
245
+
l.Error("unauthorized request", "expected", id.DID, "got", user.Did)
246
+
w.WriteHeader(http.StatusUnauthorized)
247
+
return
248
+
}
249
+
250
+
switch r.Method {
251
+
case http.MethodGet:
252
+
// return the form with prefilled fields
253
+
s.Pages.PutString(w, pages.PutStringParams{
254
+
LoggedInUser: s.OAuth.GetUser(r),
255
+
Action: "edit",
256
+
String: first,
257
+
})
258
+
case http.MethodPost:
259
+
fail := func(msg string, err error) {
260
+
l.Error(msg, "err", err)
261
+
s.Pages.Notice(w, "error", msg)
262
+
}
263
+
264
+
filename := r.FormValue("filename")
265
+
if filename == "" {
266
+
fail("Empty filename.", nil)
267
+
return
268
+
}
269
+
if !strings.Contains(filename, ".") {
270
+
// TODO: make this a htmx form validation
271
+
fail("No extension provided for filename.", nil)
272
+
return
273
+
}
274
+
275
+
content := r.FormValue("content")
276
+
if content == "" {
277
+
fail("Empty contents.", nil)
278
+
return
279
+
}
280
+
281
+
description := r.FormValue("description")
282
+
283
+
// construct new string from form values
284
+
entry := db.String{
285
+
Did: first.Did,
286
+
Rkey: first.Rkey,
287
+
Filename: filename,
288
+
Description: description,
289
+
Contents: content,
290
+
Created: first.Created,
291
+
}
292
+
293
+
record := entry.AsRecord()
294
+
295
+
client, err := s.OAuth.AuthorizedClient(r)
296
+
if err != nil {
297
+
fail("Failed to create record.", err)
298
+
return
299
+
}
300
+
301
+
// first replace the existing record in the PDS
302
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
303
+
if err != nil {
304
+
fail("Failed to updated existing record.", err)
305
+
return
306
+
}
307
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
308
+
Collection: tangled.StringNSID,
309
+
Repo: entry.Did.String(),
310
+
Rkey: entry.Rkey,
311
+
SwapRecord: ex.Cid,
312
+
Record: &lexutil.LexiconTypeDecoder{
313
+
Val: &record,
314
+
},
315
+
})
316
+
if err != nil {
317
+
fail("Failed to updated existing record.", err)
318
+
return
319
+
}
320
+
l := l.With("aturi", resp.Uri)
321
+
l.Info("edited string")
322
+
323
+
// if that went okay, updated the db
324
+
if err = db.AddString(s.Db, entry); err != nil {
325
+
fail("Failed to update string.", err)
326
+
return
327
+
}
328
+
329
+
// if that went okay, redir to the string
330
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
331
+
}
332
+
333
+
}
334
+
335
+
func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
336
+
l := s.Logger.With("handler", "create")
337
+
user := s.OAuth.GetUser(r)
338
+
339
+
switch r.Method {
340
+
case http.MethodGet:
341
+
s.Pages.PutString(w, pages.PutStringParams{
342
+
LoggedInUser: s.OAuth.GetUser(r),
343
+
Action: "new",
344
+
})
345
+
case http.MethodPost:
346
+
fail := func(msg string, err error) {
347
+
l.Error(msg, "err", err)
348
+
s.Pages.Notice(w, "error", msg)
349
+
}
350
+
351
+
filename := r.FormValue("filename")
352
+
if filename == "" {
353
+
fail("Empty filename.", nil)
354
+
return
355
+
}
356
+
if !strings.Contains(filename, ".") {
357
+
// TODO: make this a htmx form validation
358
+
fail("No extension provided for filename.", nil)
359
+
return
360
+
}
361
+
362
+
content := r.FormValue("content")
363
+
if content == "" {
364
+
fail("Empty contents.", nil)
365
+
return
366
+
}
367
+
368
+
description := r.FormValue("description")
369
+
370
+
string := db.String{
371
+
Did: syntax.DID(user.Did),
372
+
Rkey: tid.TID(),
373
+
Filename: filename,
374
+
Description: description,
375
+
Contents: content,
376
+
Created: time.Now(),
377
+
}
378
+
379
+
record := string.AsRecord()
380
+
381
+
client, err := s.OAuth.AuthorizedClient(r)
382
+
if err != nil {
383
+
fail("Failed to create record.", err)
384
+
return
385
+
}
386
+
387
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
388
+
Collection: tangled.StringNSID,
389
+
Repo: user.Did,
390
+
Rkey: string.Rkey,
391
+
Record: &lexutil.LexiconTypeDecoder{
392
+
Val: &record,
393
+
},
394
+
})
395
+
if err != nil {
396
+
fail("Failed to create record.", err)
397
+
return
398
+
}
399
+
l := l.With("aturi", resp.Uri)
400
+
l.Info("created record")
401
+
402
+
// insert into DB
403
+
if err = db.AddString(s.Db, string); err != nil {
404
+
fail("Failed to create string.", err)
405
+
return
406
+
}
407
+
408
+
// successful
409
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
410
+
}
411
+
}
412
+
413
+
func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
414
+
l := s.Logger.With("handler", "create")
415
+
user := s.OAuth.GetUser(r)
416
+
fail := func(msg string, err error) {
417
+
l.Error(msg, "err", err)
418
+
s.Pages.Notice(w, "error", msg)
419
+
}
420
+
421
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
422
+
if !ok {
423
+
l.Error("malformed middleware")
424
+
w.WriteHeader(http.StatusInternalServerError)
425
+
return
426
+
}
427
+
l = l.With("did", id.DID, "handle", id.Handle)
428
+
429
+
rkey := chi.URLParam(r, "rkey")
430
+
if rkey == "" {
431
+
l.Error("malformed url, empty rkey")
432
+
w.WriteHeader(http.StatusBadRequest)
433
+
return
434
+
}
435
+
436
+
if user.Did != id.DID.String() {
437
+
fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
438
+
return
439
+
}
440
+
441
+
if err := db.DeleteString(
442
+
s.Db,
443
+
db.FilterEq("did", user.Did),
444
+
db.FilterEq("rkey", rkey),
445
+
); err != nil {
446
+
fail("Failed to delete string.", err)
447
+
return
448
+
}
449
+
450
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
451
+
}
452
+
453
+
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
454
+
}
+1
cmd/gen.go
+1
cmd/gen.go
+9
-10
docs/hacking.md
+9
-10
docs/hacking.md
···
56
56
`nixosConfiguration` to do so.
57
57
58
58
To begin, head to `http://localhost:3000/knots` in the browser
59
-
and generate a knot secret. Replace the existing secret in
60
-
`nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated
61
-
secret.
59
+
and generate a knot secret. Set `$TANGLED_KNOT_SECRET` to it,
60
+
ideally in a `.envrc` with [direnv](https://direnv.net) so you
61
+
don't lose it.
62
62
63
63
You can now start a lightweight NixOS VM using
64
64
`nixos-shell` like so:
···
91
91
92
92
## running a spindle
93
93
94
-
Be sure to change the `owner` field for the spindle in
95
-
`nix/vm.nix` to your own DID. The above VM should already
96
-
be running a spindle on `localhost:6555`. You can head to
97
-
the spindle dashboard on `http://localhost:3000/spindles`,
98
-
and register a spindle with hostname `localhost:6555`. It
99
-
should instantly be verified. You can then configure each
100
-
repository to use this spindle and run CI jobs.
94
+
Be sure to set `$TANGLED_SPINDLE_OWNER` to your own DID.
95
+
The above VM should already be running a spindle on `localhost:6555`.
96
+
You can head to the spindle dashboard on `http://localhost:3000/spindles`,
97
+
and register a spindle with hostname `localhost:6555`. It should instantly
98
+
be verified. You can then configure each repository to use this spindle
99
+
and run CI jobs.
101
100
102
101
Of interest when debugging spindles:
103
102
+1
-1
docs/knot-hosting.md
+1
-1
docs/knot-hosting.md
+1
-1
docs/spindle/openbao.md
+1
-1
docs/spindle/openbao.md
···
114
114
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
115
115
116
116
# Generate secret ID
117
-
SECRET_ID=$(bao write -field=secret_id auth/approle/role/spindle/secret-id)
117
+
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
118
118
119
119
echo "Role ID: $ROLE_ID"
120
120
echo "Secret ID: $SECRET_ID"
+7
-28
flake.lock
+7
-28
flake.lock
···
1
1
{
2
2
"nodes": {
3
-
"gitignore": {
4
-
"inputs": {
5
-
"nixpkgs": [
6
-
"nixpkgs"
7
-
]
8
-
},
9
-
"locked": {
10
-
"lastModified": 1709087332,
11
-
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
12
-
"owner": "hercules-ci",
13
-
"repo": "gitignore.nix",
14
-
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
15
-
"type": "github"
16
-
},
17
-
"original": {
18
-
"owner": "hercules-ci",
19
-
"repo": "gitignore.nix",
20
-
"type": "github"
21
-
}
22
-
},
23
3
"flake-utils": {
24
4
"inputs": {
25
5
"systems": "systems"
···
99
79
"indigo": {
100
80
"flake": false,
101
81
"locked": {
102
-
"lastModified": 1745333930,
103
-
"narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=",
82
+
"lastModified": 1753693716,
83
+
"narHash": "sha256-DMIKnCJRODQXEHUxA+7mLzRALmnZhkkbHlFT2rCQYrE=",
104
84
"owner": "oppiliappan",
105
85
"repo": "indigo",
106
-
"rev": "e4e59280737b8676611fc077a228d47b3e8e9491",
86
+
"rev": "5f170569da9360f57add450a278d73538092d8ca",
107
87
"type": "github"
108
88
},
109
89
"original": {
···
128
108
"lucide-src": {
129
109
"flake": false,
130
110
"locked": {
131
-
"lastModified": 1742302029,
132
-
"narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=",
111
+
"lastModified": 1754044466,
112
+
"narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=",
133
113
"type": "tarball",
134
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
114
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
135
115
},
136
116
"original": {
137
117
"type": "tarball",
138
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
118
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
139
119
}
140
120
},
141
121
"nixpkgs": {
···
156
136
},
157
137
"root": {
158
138
"inputs": {
159
-
"gitignore": "gitignore",
160
139
"gomod2nix": "gomod2nix",
161
140
"htmx-src": "htmx-src",
162
141
"htmx-ws-src": "htmx-ws-src",
+74
-26
flake.nix
+74
-26
flake.nix
···
22
22
flake = false;
23
23
};
24
24
lucide-src = {
25
-
url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip";
25
+
url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip";
26
26
flake = false;
27
27
};
28
28
inter-fonts-src = {
···
37
37
url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip";
38
38
flake = false;
39
39
};
40
-
gitignore = {
41
-
url = "github:hercules-ci/gitignore.nix";
42
-
inputs.nixpkgs.follows = "nixpkgs";
43
-
};
44
40
};
45
41
46
42
outputs = {
···
51
47
htmx-src,
52
48
htmx-ws-src,
53
49
lucide-src,
54
-
gitignore,
55
50
inter-fonts-src,
56
51
sqlite-lib-src,
57
52
ibm-plex-mono-src,
···
62
57
63
58
mkPackageSet = pkgs:
64
59
pkgs.lib.makeScope pkgs.newScope (self: {
65
-
inherit (gitignore.lib) gitignoreSource;
60
+
src = let
61
+
fs = pkgs.lib.fileset;
62
+
in
63
+
fs.toSource {
64
+
root = ./.;
65
+
fileset = fs.difference (fs.intersection (fs.gitTracked ./.) (fs.fileFilter (file: !(file.hasExt "nix")) ./.)) (fs.maybeMissing ./.jj);
66
+
};
66
67
buildGoApplication =
67
68
(self.callPackage "${gomod2nix}/builder" {
68
69
gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix;
···
74
75
};
75
76
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
76
77
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
77
-
appview = self.callPackage ./nix/pkgs/appview.nix {
78
+
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
78
79
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
79
80
};
81
+
appview = self.callPackage ./nix/pkgs/appview.nix {};
80
82
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
81
83
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
82
84
knot = self.callPackage ./nix/pkgs/knot.nix {};
···
92
94
staticPackages = mkPackageSet pkgs.pkgsStatic;
93
95
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
94
96
in {
95
-
appview = packages.appview;
96
-
lexgen = packages.lexgen;
97
-
knot = packages.knot;
98
-
knot-unwrapped = packages.knot-unwrapped;
99
-
spindle = packages.spindle;
100
-
genjwks = packages.genjwks;
101
-
sqlite-lib = packages.sqlite-lib;
97
+
inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib;
102
98
103
99
pkgsStatic-appview = staticPackages.appview;
104
100
pkgsStatic-knot = staticPackages.knot;
···
131
127
pkgs.tailwindcss
132
128
pkgs.nixos-shell
133
129
pkgs.redis
130
+
pkgs.coreutils # for those of us who are on systems that use busybox (alpine)
134
131
packages'.lexgen
135
132
];
136
133
shellHook = ''
137
-
mkdir -p appview/pages/static/{fonts,icons}
138
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
139
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
140
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
141
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
142
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
143
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
134
+
mkdir -p appview/pages/static
135
+
# no preserve is needed because watch-tailwind will want to be able to overwrite
136
+
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
144
137
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
145
138
'';
146
139
env.CGO_ENABLED = 1;
···
148
141
});
149
142
apps = forAllSystems (system: let
150
143
pkgs = nixpkgsFor."${system}";
144
+
packages' = self.packages.${system};
151
145
air-watcher = name: arg:
152
146
pkgs.writeShellScriptBin "run"
153
147
''
···
166
160
in {
167
161
watch-appview = {
168
162
type = "app";
169
-
program = ''${air-watcher "appview" ""}/bin/run'';
163
+
program = toString (pkgs.writeShellScript "watch-appview" ''
164
+
echo "copying static files to appview/pages/static..."
165
+
${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
166
+
${air-watcher "appview" ""}/bin/run
167
+
'');
170
168
};
171
169
watch-knot = {
172
170
type = "app";
···
176
174
type = "app";
177
175
program = ''${tailwind-watcher}/bin/run'';
178
176
};
179
-
vm = {
177
+
vm = let
178
+
system =
179
+
if pkgs.stdenv.hostPlatform.isAarch64
180
+
then "aarch64"
181
+
else "x86_64";
182
+
183
+
nixos-shell = pkgs.nixos-shell.overrideAttrs (old: {
184
+
patches =
185
+
(old.patches or [])
186
+
++ [
187
+
# https://github.com/Mic92/nixos-shell/pull/94
188
+
(pkgs.fetchpatch {
189
+
name = "fix-foreign-vm.patch";
190
+
url = "https://github.com/Mic92/nixos-shell/commit/113e4cc55ae236b5b0b1fbd8b321e9b67c77580e.patch";
191
+
hash = "sha256-eauetBK0wXAOcd9PYbExokNCiwz2QyFnZ4FnwGi9VCo=";
192
+
})
193
+
];
194
+
});
195
+
in {
180
196
type = "app";
181
197
program = toString (pkgs.writeShellScript "vm" ''
182
-
${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm
198
+
${nixos-shell}/bin/nixos-shell --flake .#vm-${system} --guest-system ${system}-linux
183
199
'');
184
200
};
185
201
gomod2nix = {
···
188
204
${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix
189
205
'');
190
206
};
207
+
lexgen = {
208
+
type = "app";
209
+
program =
210
+
(pkgs.writeShellApplication {
211
+
name = "lexgen";
212
+
text = ''
213
+
if ! command -v lexgen > /dev/null; then
214
+
echo "error: must be executed from devshell"
215
+
exit 1
216
+
fi
217
+
218
+
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
219
+
cd "$rootDir"
220
+
221
+
rm api/tangled/*
222
+
lexgen --build-file lexicon-build-config.json lexicons
223
+
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
224
+
${pkgs.gotools}/bin/goimports -w api/tangled/*
225
+
go run cmd/gen.go
226
+
lexgen --build-file lexicon-build-config.json lexicons
227
+
rm api/tangled/*.bak
228
+
'';
229
+
})
230
+
+ /bin/lexgen;
231
+
};
191
232
});
192
233
193
234
nixosModules.appview = {
···
217
258
218
259
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
219
260
};
220
-
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
261
+
nixosConfigurations.vm-x86_64 = import ./nix/vm.nix {
262
+
inherit self nixpkgs;
263
+
system = "x86_64-linux";
264
+
};
265
+
nixosConfigurations.vm-aarch64 = import ./nix/vm.nix {
266
+
inherit self nixpkgs;
267
+
system = "aarch64-linux";
268
+
};
221
269
};
222
270
}
+13
jetstream/jetstream.go
+13
jetstream/jetstream.go
···
52
52
j.mu.Unlock()
53
53
}
54
54
55
+
func (j *JetstreamClient) RemoveDid(did string) {
56
+
if did == "" {
57
+
return
58
+
}
59
+
60
+
if j.logDids {
61
+
j.l.Info("removing did from in-memory filter", "did", did)
62
+
}
63
+
j.mu.Lock()
64
+
delete(j.wantedDids, did)
65
+
j.mu.Unlock()
66
+
}
67
+
55
68
type processor func(context.Context, *models.Event) error
56
69
57
70
func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
-8
knotserver/file.go
-8
knotserver/file.go
···
10
10
"tangled.sh/tangled.sh/core/types"
11
11
)
12
12
13
-
func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) {
14
-
data["files"] = files
15
-
16
-
writeJSON(w, data)
17
-
return
18
-
}
19
-
20
13
func countLines(r io.Reader) (int, error) {
21
14
buf := make([]byte, 32*1024)
22
15
bufLen := 0
···
52
45
53
46
resp.Lines = lc
54
47
writeJSON(w, resp)
55
-
return
56
48
}
+61
-1
knotserver/ingester.go
+61
-1
knotserver/ingester.go
···
213
213
return h.db.InsertEvent(event, h.n)
214
214
}
215
215
216
+
// duplicated from add collaborator
217
+
func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error {
218
+
repoAt, err := syntax.ParseATURI(record.Repo)
219
+
if err != nil {
220
+
return err
221
+
}
222
+
223
+
resolver := idresolver.DefaultResolver()
224
+
225
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
226
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
227
+
return err
228
+
}
229
+
230
+
// TODO: fix this for good, we need to fetch the record here unfortunately
231
+
// resolve this aturi to extract the repo record
232
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
233
+
if err != nil || owner.Handle.IsInvalidHandle() {
234
+
return fmt.Errorf("failed to resolve handle: %w", err)
235
+
}
236
+
237
+
xrpcc := xrpc.Client{
238
+
Host: owner.PDSEndpoint(),
239
+
}
240
+
241
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
242
+
if err != nil {
243
+
return err
244
+
}
245
+
246
+
repo := resp.Value.Val.(*tangled.Repo)
247
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
248
+
249
+
// check perms for this user
250
+
if ok, err := h.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil {
251
+
return fmt.Errorf("insufficient permissions: %w", err)
252
+
}
253
+
254
+
if err := h.db.AddDid(subjectId.DID.String()); err != nil {
255
+
return err
256
+
}
257
+
h.jc.AddDid(subjectId.DID.String())
258
+
259
+
if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil {
260
+
return err
261
+
}
262
+
263
+
return h.fetchAndAddKeys(ctx, subjectId.DID.String())
264
+
}
265
+
216
266
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
217
267
l := log.FromContext(ctx)
218
268
···
266
316
defer func() {
267
317
eventTime := event.TimeUS
268
318
lastTimeUs := eventTime + 1
269
-
fmt.Println("lastTimeUs", lastTimeUs)
270
319
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
271
320
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
272
321
}
···
292
341
if err := h.processKnotMember(ctx, did, record); err != nil {
293
342
return fmt.Errorf("failed to process knot member: %w", err)
294
343
}
344
+
295
345
case tangled.RepoPullNSID:
296
346
var record tangled.RepoPull
297
347
if err := json.Unmarshal(raw, &record); err != nil {
···
300
350
if err := h.processPull(ctx, did, record); err != nil {
301
351
return fmt.Errorf("failed to process knot member: %w", err)
302
352
}
353
+
354
+
case tangled.RepoCollaboratorNSID:
355
+
var record tangled.RepoCollaborator
356
+
if err := json.Unmarshal(raw, &record); err != nil {
357
+
return fmt.Errorf("failed to unmarshal record: %w", err)
358
+
}
359
+
if err := h.processCollaborator(ctx, did, record); err != nil {
360
+
return fmt.Errorf("failed to process knot member: %w", err)
361
+
}
362
+
303
363
}
304
364
305
365
return err
+1
knotserver/server.go
+1
knotserver/server.go
+40
lexicons/string/string.json
+40
lexicons/string/string.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.string",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"filename",
14
+
"description",
15
+
"createdAt",
16
+
"contents"
17
+
],
18
+
"properties": {
19
+
"filename": {
20
+
"type": "string",
21
+
"maxGraphemes": 140,
22
+
"minGraphemes": 1
23
+
},
24
+
"description": {
25
+
"type": "string",
26
+
"maxGraphemes": 280
27
+
},
28
+
"createdAt": {
29
+
"type": "string",
30
+
"format": "datetime"
31
+
},
32
+
"contents": {
33
+
"type": "string",
34
+
"minGraphemes": 1
35
+
}
36
+
}
37
+
}
38
+
}
39
+
}
40
+
}
+6
nix/gomod2nix.toml
+6
nix/gomod2nix.toml
···
66
66
[mod."github.com/cloudflare/circl"]
67
67
version = "v1.6.2-0.20250618153321-aa837fd1539d"
68
68
hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y="
69
+
[mod."github.com/cloudflare/cloudflare-go"]
70
+
version = "v0.115.0"
71
+
hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw="
69
72
[mod."github.com/containerd/errdefs"]
70
73
version = "v1.0.0"
71
74
hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI="
···
169
172
[mod."github.com/golang/mock"]
170
173
version = "v1.6.0"
171
174
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
175
+
[mod."github.com/google/go-querystring"]
176
+
version = "v1.1.0"
177
+
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
172
178
[mod."github.com/google/uuid"]
173
179
version = "v1.6.0"
174
180
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
+22
nix/modules/spindle.nix
+22
nix/modules/spindle.nix
···
54
54
example = "did:plc:qfpnj4og54vl56wngdriaxug";
55
55
description = "DID of owner (required)";
56
56
};
57
+
58
+
secrets = {
59
+
provider = mkOption {
60
+
type = types.str;
61
+
default = "sqlite";
62
+
description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'.";
63
+
};
64
+
65
+
openbao = {
66
+
proxyAddr = mkOption {
67
+
type = types.str;
68
+
default = "http://127.0.0.1:8200";
69
+
};
70
+
mount = mkOption {
71
+
type = types.str;
72
+
default = "spindle";
73
+
};
74
+
};
75
+
};
57
76
};
58
77
59
78
pipelines = {
···
89
108
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
90
109
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
91
110
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
111
+
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
112
+
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
113
+
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
92
114
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
93
115
"SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
94
116
];
+23
nix/pkgs/appview-static-files.nix
+23
nix/pkgs/appview-static-files.nix
···
1
+
{
2
+
runCommandLocal,
3
+
htmx-src,
4
+
htmx-ws-src,
5
+
lucide-src,
6
+
inter-fonts-src,
7
+
ibm-plex-mono-src,
8
+
sqlite-lib,
9
+
tailwindcss,
10
+
src,
11
+
}:
12
+
runCommandLocal "appview-static-files" {} ''
13
+
mkdir -p $out/{fonts,icons} && cd $out
14
+
cp -f ${htmx-src} htmx.min.js
15
+
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
16
+
cp -rf ${lucide-src}/*.svg icons/
17
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
18
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
19
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/
20
+
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
21
+
# for whatever reason (produces broken css), so we are doing this instead
22
+
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
23
+
''
+5
-17
nix/pkgs/appview.nix
+5
-17
nix/pkgs/appview.nix
···
1
1
{
2
2
buildGoApplication,
3
3
modules,
4
-
htmx-src,
5
-
htmx-ws-src,
6
-
lucide-src,
7
-
inter-fonts-src,
8
-
ibm-plex-mono-src,
9
-
tailwindcss,
4
+
appview-static-files,
10
5
sqlite-lib,
11
-
gitignoreSource,
6
+
src,
12
7
}:
13
8
buildGoApplication {
14
9
pname = "appview";
15
10
version = "0.1.0";
16
-
src = gitignoreSource ../..;
17
-
inherit modules;
11
+
inherit src modules;
18
12
19
13
postUnpack = ''
20
14
pushd source
21
-
mkdir -p appview/pages/static/{fonts,icons}
22
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
23
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
24
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
25
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
26
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
27
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
28
-
${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css
15
+
mkdir -p appview/pages/static
16
+
cp -frv ${appview-static-files}/* appview/pages/static
29
17
popd
30
18
'';
31
19
+2
-3
nix/pkgs/genjwks.nix
+2
-3
nix/pkgs/genjwks.nix
···
1
1
{
2
-
gitignoreSource,
2
+
src,
3
3
buildGoApplication,
4
4
modules,
5
5
}:
6
6
buildGoApplication {
7
7
pname = "genjwks";
8
8
version = "0.1.0";
9
-
src = gitignoreSource ../..;
10
-
inherit modules;
9
+
inherit src modules;
11
10
subPackages = ["cmd/genjwks"];
12
11
doCheck = false;
13
12
CGO_ENABLED = 0;
+2
-3
nix/pkgs/knot-unwrapped.nix
+2
-3
nix/pkgs/knot-unwrapped.nix
+1
-1
nix/pkgs/lexgen.nix
+1
-1
nix/pkgs/lexgen.nix
+2
-3
nix/pkgs/spindle.nix
+2
-3
nix/pkgs/spindle.nix
+81
-63
nix/vm.nix
+81
-63
nix/vm.nix
···
1
1
{
2
2
nixpkgs,
3
+
system,
3
4
self,
4
-
}:
5
-
nixpkgs.lib.nixosSystem {
6
-
system = "x86_64-linux";
7
-
modules = [
8
-
self.nixosModules.knot
9
-
self.nixosModules.spindle
10
-
({
11
-
config,
12
-
pkgs,
13
-
...
14
-
}: {
15
-
virtualisation = {
16
-
memorySize = 2048;
17
-
diskSize = 10 * 1024;
18
-
cores = 2;
19
-
forwardPorts = [
20
-
# ssh
21
-
{
22
-
from = "host";
23
-
host.port = 2222;
24
-
guest.port = 22;
25
-
}
26
-
# knot
27
-
{
28
-
from = "host";
29
-
host.port = 6000;
30
-
guest.port = 6000;
31
-
}
32
-
# spindle
33
-
{
34
-
from = "host";
35
-
host.port = 6555;
36
-
guest.port = 6555;
37
-
}
5
+
}: let
6
+
envVar = name: let
7
+
var = builtins.getEnv name;
8
+
in
9
+
if var == ""
10
+
then throw "\$${name} must be defined, see docs/hacking.md for more details"
11
+
else var;
12
+
in
13
+
nixpkgs.lib.nixosSystem {
14
+
inherit system;
15
+
modules = [
16
+
self.nixosModules.knot
17
+
self.nixosModules.spindle
18
+
({
19
+
config,
20
+
pkgs,
21
+
...
22
+
}: {
23
+
nixos-shell = {
24
+
inheritPath = false;
25
+
mounts = {
26
+
mountHome = false;
27
+
mountNixProfile = false;
28
+
};
29
+
};
30
+
virtualisation = {
31
+
memorySize = 2048;
32
+
diskSize = 10 * 1024;
33
+
cores = 2;
34
+
forwardPorts = [
35
+
# ssh
36
+
{
37
+
from = "host";
38
+
host.port = 2222;
39
+
guest.port = 22;
40
+
}
41
+
# knot
42
+
{
43
+
from = "host";
44
+
host.port = 6000;
45
+
guest.port = 6000;
46
+
}
47
+
# spindle
48
+
{
49
+
from = "host";
50
+
host.port = 6555;
51
+
guest.port = 6555;
52
+
}
53
+
];
54
+
};
55
+
services.getty.autologinUser = "root";
56
+
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
57
+
systemd.tmpfiles.rules = let
58
+
u = config.services.tangled-knot.gitUser;
59
+
g = config.services.tangled-knot.gitUser;
60
+
in [
61
+
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
62
+
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=${envVar "TANGLED_VM_KNOT_SECRET"}"
38
63
];
39
-
};
40
-
services.getty.autologinUser = "root";
41
-
environment.systemPackages = with pkgs; [curl vim git];
42
-
systemd.tmpfiles.rules = let
43
-
u = config.services.tangled-knot.gitUser;
44
-
g = config.services.tangled-knot.gitUser;
45
-
in [
46
-
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
47
-
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440"
48
-
];
49
-
services.tangled-knot = {
50
-
enable = true;
51
-
motd = "Welcome to the development knot!\n";
52
-
server = {
53
-
secretFile = "/var/lib/knot/secret";
54
-
hostname = "localhost:6000";
55
-
listenAddr = "0.0.0.0:6000";
64
+
services.tangled-knot = {
65
+
enable = true;
66
+
motd = "Welcome to the development knot!\n";
67
+
server = {
68
+
secretFile = "/var/lib/knot/secret";
69
+
hostname = "localhost:6000";
70
+
listenAddr = "0.0.0.0:6000";
71
+
};
56
72
};
57
-
};
58
-
services.tangled-spindle = {
59
-
enable = true;
60
-
server = {
61
-
owner = "did:plc:qfpnj4og54vl56wngdriaxug";
62
-
hostname = "localhost:6555";
63
-
listenAddr = "0.0.0.0:6555";
64
-
dev = true;
73
+
services.tangled-spindle = {
74
+
enable = true;
75
+
server = {
76
+
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
77
+
hostname = "localhost:6555";
78
+
listenAddr = "0.0.0.0:6555";
79
+
dev = true;
80
+
secrets = {
81
+
provider = "sqlite";
82
+
};
83
+
};
65
84
};
66
-
};
67
-
})
68
-
];
69
-
}
85
+
})
86
+
];
87
+
}
+15
spindle/db/db.go
+15
spindle/db/db.go
···
45
45
unique(owner, name)
46
46
);
47
47
48
+
create table if not exists spindle_members (
49
+
-- identifiers for the record
50
+
id integer primary key autoincrement,
51
+
did text not null,
52
+
rkey text not null,
53
+
54
+
-- data
55
+
instance text not null,
56
+
subject text not null,
57
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
58
+
59
+
-- constraints
60
+
unique (did, instance, subject)
61
+
);
62
+
48
63
-- status event for a single workflow
49
64
create table if not exists events (
50
65
rkey text not null,
+59
spindle/db/member.go
+59
spindle/db/member.go
···
1
+
package db
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type SpindleMember struct {
10
+
Id int
11
+
Did syntax.DID // owner of the record
12
+
Rkey string // rkey of the record
13
+
Instance string
14
+
Subject syntax.DID // the member being added
15
+
Created time.Time
16
+
}
17
+
18
+
func AddSpindleMember(db *DB, member SpindleMember) error {
19
+
_, err := db.Exec(
20
+
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
21
+
member.Did,
22
+
member.Rkey,
23
+
member.Instance,
24
+
member.Subject,
25
+
)
26
+
return err
27
+
}
28
+
29
+
func RemoveSpindleMember(db *DB, owner_did, rkey string) error {
30
+
_, err := db.Exec(
31
+
"delete from spindle_members where did = ? and rkey = ?",
32
+
owner_did,
33
+
rkey,
34
+
)
35
+
return err
36
+
}
37
+
38
+
func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) {
39
+
query :=
40
+
`select id, did, rkey, instance, subject, created
41
+
from spindle_members
42
+
where did = ? and rkey = ?`
43
+
44
+
var member SpindleMember
45
+
var createdAt string
46
+
err := db.QueryRow(query, did, rkey).Scan(
47
+
&member.Id,
48
+
&member.Did,
49
+
&member.Rkey,
50
+
&member.Instance,
51
+
&member.Subject,
52
+
&createdAt,
53
+
)
54
+
if err != nil {
55
+
return nil, err
56
+
}
57
+
58
+
return &member, nil
59
+
}
+161
-7
spindle/ingester.go
+161
-7
spindle/ingester.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
-
"path/filepath"
8
+
"time"
8
9
9
10
"tangled.sh/tangled.sh/core/api/tangled"
10
11
"tangled.sh/tangled.sh/core/eventconsumer"
12
+
"tangled.sh/tangled.sh/core/idresolver"
11
13
"tangled.sh/tangled.sh/core/rbac"
14
+
"tangled.sh/tangled.sh/core/spindle/db"
12
15
16
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
17
+
"github.com/bluesky-social/indigo/atproto/identity"
18
+
"github.com/bluesky-social/indigo/atproto/syntax"
19
+
"github.com/bluesky-social/indigo/xrpc"
13
20
"github.com/bluesky-social/jetstream/pkg/models"
21
+
securejoin "github.com/cyphar/filepath-securejoin"
14
22
)
15
23
16
24
type Ingester func(ctx context.Context, e *models.Event) error
···
35
43
s.ingestMember(ctx, e)
36
44
case tangled.RepoNSID:
37
45
s.ingestRepo(ctx, e)
46
+
case tangled.RepoCollaboratorNSID:
47
+
s.ingestCollaborator(ctx, e)
38
48
}
39
49
40
50
return err
···
42
52
}
43
53
44
54
func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error {
55
+
var err error
45
56
did := e.Did
46
-
var err error
57
+
rkey := e.Commit.RKey
47
58
48
59
l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID)
49
60
···
58
69
}
59
70
60
71
domain := s.cfg.Server.Hostname
61
-
if s.cfg.Server.Dev {
62
-
domain = s.cfg.Server.ListenAddr
63
-
}
64
72
recordInstance := record.Instance
65
73
66
74
if recordInstance != domain {
···
74
82
return fmt.Errorf("failed to enforce permissions: %w", err)
75
83
}
76
84
85
+
if err := db.AddSpindleMember(s.db, db.SpindleMember{
86
+
Did: syntax.DID(did),
87
+
Rkey: rkey,
88
+
Instance: recordInstance,
89
+
Subject: syntax.DID(record.Subject),
90
+
Created: time.Now(),
91
+
}); err != nil {
92
+
l.Error("failed to add member", "error", err)
93
+
return fmt.Errorf("failed to add member: %w", err)
94
+
}
95
+
77
96
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
78
97
l.Error("failed to add member", "error", err)
79
98
return fmt.Errorf("failed to add member: %w", err)
···
88
107
89
108
return nil
90
109
110
+
case models.CommitOperationDelete:
111
+
record, err := db.GetSpindleMember(s.db, did, rkey)
112
+
if err != nil {
113
+
l.Error("failed to find member", "error", err)
114
+
return fmt.Errorf("failed to find member: %w", err)
115
+
}
116
+
117
+
if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil {
118
+
l.Error("failed to remove member", "error", err)
119
+
return fmt.Errorf("failed to remove member: %w", err)
120
+
}
121
+
122
+
if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil {
123
+
l.Error("failed to add member", "error", err)
124
+
return fmt.Errorf("failed to add member: %w", err)
125
+
}
126
+
l.Info("added member from firehose", "member", record.Subject)
127
+
128
+
if err := s.db.RemoveDid(record.Subject.String()); err != nil {
129
+
l.Error("failed to add did", "error", err)
130
+
return fmt.Errorf("failed to add did: %w", err)
131
+
}
132
+
s.jc.RemoveDid(record.Subject.String())
133
+
91
134
}
92
135
return nil
93
136
}
94
137
95
-
func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error {
138
+
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
96
139
var err error
140
+
did := e.Did
141
+
resolver := idresolver.DefaultResolver()
97
142
98
143
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
99
144
···
129
174
return fmt.Errorf("failed to add repo: %w", err)
130
175
}
131
176
177
+
didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name)
178
+
if err != nil {
179
+
return err
180
+
}
181
+
132
182
// add repo to rbac
133
-
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, filepath.Join(record.Owner, record.Name)); err != nil {
183
+
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil {
134
184
l.Error("failed to add repo to enforcer", "error", err)
135
185
return fmt.Errorf("failed to add repo: %w", err)
136
186
}
137
187
188
+
// add collaborators to rbac
189
+
owner, err := resolver.ResolveIdent(ctx, did)
190
+
if err != nil || owner.Handle.IsInvalidHandle() {
191
+
return err
192
+
}
193
+
if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil {
194
+
return err
195
+
}
196
+
138
197
// add this knot to the event consumer
139
198
src := eventconsumer.NewKnotSource(record.Knot)
140
199
s.ks.AddSource(context.Background(), src)
···
144
203
}
145
204
return nil
146
205
}
206
+
207
+
func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error {
208
+
var err error
209
+
210
+
l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did)
211
+
212
+
l.Info("ingesting collaborator record")
213
+
214
+
switch e.Commit.Operation {
215
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
216
+
raw := e.Commit.Record
217
+
record := tangled.RepoCollaborator{}
218
+
err = json.Unmarshal(raw, &record)
219
+
if err != nil {
220
+
l.Error("invalid record", "error", err)
221
+
return err
222
+
}
223
+
224
+
resolver := idresolver.DefaultResolver()
225
+
226
+
subjectId, err := resolver.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 := resolver.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/secrets/openbao.go
+1
-1
spindle/secrets/openbao.go
+15
-1
spindle/server.go
+15
-1
spindle/server.go
···
98
98
return err
99
99
}
100
100
101
-
jq := queue.NewQueue(100, 2)
101
+
jq := queue.NewQueue(100, 5)
102
102
103
103
collections := []string{
104
104
tangled.SpindleMemberNSID,
105
105
tangled.RepoNSID,
106
+
tangled.RepoCollaboratorNSID,
106
107
}
107
108
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
108
109
if err != nil {
109
110
return fmt.Errorf("failed to setup jetstream client: %w", err)
110
111
}
111
112
jc.AddDid(cfg.Server.Owner)
113
+
114
+
// Check if the spindle knows about any Dids;
115
+
dids, err := d.GetAllDids()
116
+
if err != nil {
117
+
return fmt.Errorf("failed to get all dids: %w", err)
118
+
}
119
+
for _, d := range dids {
120
+
jc.AddDid(d)
121
+
}
112
122
113
123
resolver := idresolver.DefaultResolver()
114
124
···
230
240
231
241
if tpl.TriggerMetadata.Repo == nil {
232
242
return fmt.Errorf("no repo data found")
243
+
}
244
+
245
+
if src.Key() != tpl.TriggerMetadata.Repo.Knot {
246
+
return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot)
233
247
}
234
248
235
249
// filter by repos