+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 {
+17
-2
appview/db/db.go
+17
-2
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
···
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
-4
appview/pages/templates/layouts/topbar.html
+25
-4
appview/pages/templates/layouts/topbar.html
···
7
7
</a>
8
8
</div>
9
9
10
-
<div id="right-items" class="flex items-center gap-4">
10
+
<div id="right-items" class="flex items-center gap-2">
11
11
{{ with .LoggedInUser }}
12
-
<a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white">
13
-
{{ i "plus" "w-4 h-4" }}
14
-
</a>
12
+
{{ block "newButton" . }} {{ end }}
15
13
{{ block "dropDown" . }} {{ end }}
16
14
{{ else }}
17
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>
18
20
{{ end }}
19
21
</div>
20
22
</div>
21
23
</nav>
22
24
{{ end }}
23
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
+
24
44
{{ define "dropDown" }}
25
45
<details class="relative inline-block text-left">
26
46
<summary
···
34
54
>
35
55
<a href="/{{ $user }}">profile</a>
36
56
<a href="/{{ $user }}?tab=repos">repositories</a>
57
+
<a href="/strings/{{ $user }}">strings</a>
37
58
<a href="/knots">knots</a>
38
59
<a href="/spindles">spindles</a>
39
60
<a href="/settings">settings</a>
+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
-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
+
+3
appview/repo/repo.go
+3
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)
+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
}
-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
}
+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
+
}
+39
-4
spindle/ingester.go
+39
-4
spindle/ingester.go
···
5
5
"encoding/json"
6
6
"errors"
7
7
"fmt"
8
+
"time"
8
9
9
10
"tangled.sh/tangled.sh/core/api/tangled"
10
11
"tangled.sh/tangled.sh/core/eventconsumer"
11
12
"tangled.sh/tangled.sh/core/idresolver"
12
13
"tangled.sh/tangled.sh/core/rbac"
14
+
"tangled.sh/tangled.sh/core/spindle/db"
13
15
14
16
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
17
"github.com/bluesky-social/indigo/atproto/identity"
···
50
52
}
51
53
52
54
func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error {
55
+
var err error
53
56
did := e.Did
54
-
var err error
57
+
rkey := e.Commit.RKey
55
58
56
59
l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID)
57
60
···
66
69
}
67
70
68
71
domain := s.cfg.Server.Hostname
69
-
if s.cfg.Server.Dev {
70
-
domain = s.cfg.Server.ListenAddr
71
-
}
72
72
recordInstance := record.Instance
73
73
74
74
if recordInstance != domain {
···
82
82
return fmt.Errorf("failed to enforce permissions: %w", err)
83
83
}
84
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
+
85
96
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
86
97
l.Error("failed to add member", "error", err)
87
98
return fmt.Errorf("failed to add member: %w", err)
···
95
106
s.jc.AddDid(record.Subject)
96
107
97
108
return nil
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())
98
133
99
134
}
100
135
return nil
+1
-1
spindle/secrets/openbao.go
+1
-1
spindle/secrets/openbao.go
+14
-1
spindle/server.go
+14
-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,
···
110
110
return fmt.Errorf("failed to setup jetstream client: %w", err)
111
111
}
112
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
+
}
113
122
114
123
resolver := idresolver.DefaultResolver()
115
124
···
231
240
232
241
if tpl.TriggerMetadata.Repo == nil {
233
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)
234
247
}
235
248
236
249
// filter by repos