Signed-off-by: oppiliappan me@oppi.li
+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
+
}
+1
cmd/gen.go
+1
cmd/gen.go
+40
lexicons/string/string.json
+40
lexicons/string/string.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.string",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"filename",
14
+
"description",
15
+
"createdAt",
16
+
"contents"
17
+
],
18
+
"properties": {
19
+
"filename": {
20
+
"type": "string",
21
+
"maxGraphemes": 140,
22
+
"minGraphemes": 1
23
+
},
24
+
"description": {
25
+
"type": "string",
26
+
"maxGraphemes": 280
27
+
},
28
+
"createdAt": {
29
+
"type": "string",
30
+
"format": "datetime"
31
+
},
32
+
"contents": {
33
+
"type": "string",
34
+
"minGraphemes": 1
35
+
}
36
+
}
37
+
}
38
+
}
39
+
}
40
+
}
+15
appview/db/db.go
+15
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
+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
+
}
+449
appview/strings/strings.go
+449
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("incorrect number of records returned", "len(strings)", len(strings))
104
+
w.WriteHeader(http.StatusInternalServerError)
105
+
return
106
+
}
107
+
string := strings[0]
108
+
109
+
if path.Base(r.URL.Path) == "raw" {
110
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
111
+
if string.Filename != "" {
112
+
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename))
113
+
}
114
+
w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents)))
115
+
116
+
_, err = w.Write([]byte(string.Contents))
117
+
if err != nil {
118
+
l.Error("failed to write raw response", "err", err)
119
+
}
120
+
return
121
+
}
122
+
123
+
var showRendered, renderToggle bool
124
+
if markup.GetFormat(string.Filename) == markup.FormatMarkdown {
125
+
renderToggle = true
126
+
showRendered = r.URL.Query().Get("code") != "true"
127
+
}
128
+
129
+
s.Pages.SingleString(w, pages.SingleStringParams{
130
+
LoggedInUser: s.OAuth.GetUser(r),
131
+
RenderToggle: renderToggle,
132
+
ShowRendered: showRendered,
133
+
String: string,
134
+
Stats: string.Stats(),
135
+
Owner: id,
136
+
})
137
+
}
138
+
139
+
func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
140
+
l := s.Logger.With("handler", "dashboard")
141
+
142
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
143
+
if !ok {
144
+
l.Error("malformed middleware")
145
+
w.WriteHeader(http.StatusInternalServerError)
146
+
return
147
+
}
148
+
l = l.With("did", id.DID, "handle", id.Handle)
149
+
150
+
all, err := db.GetStrings(
151
+
s.Db,
152
+
db.FilterEq("did", id.DID),
153
+
)
154
+
if err != nil {
155
+
l.Error("failed to fetch strings", "err", err)
156
+
w.WriteHeader(http.StatusInternalServerError)
157
+
return
158
+
}
159
+
160
+
slices.SortFunc(all, func(a, b db.String) int {
161
+
if a.Created.After(b.Created) {
162
+
return -1
163
+
} else {
164
+
return 1
165
+
}
166
+
})
167
+
168
+
profile, err := db.GetProfile(s.Db, id.DID.String())
169
+
if err != nil {
170
+
l.Error("failed to fetch user profile", "err", err)
171
+
w.WriteHeader(http.StatusInternalServerError)
172
+
return
173
+
}
174
+
loggedInUser := s.OAuth.GetUser(r)
175
+
followStatus := db.IsNotFollowing
176
+
if loggedInUser != nil {
177
+
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
178
+
}
179
+
180
+
followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String())
181
+
if err != nil {
182
+
l.Error("failed to get follow stats", "err", err)
183
+
}
184
+
185
+
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
186
+
LoggedInUser: s.OAuth.GetUser(r),
187
+
Card: pages.ProfileCard{
188
+
UserDid: id.DID.String(),
189
+
UserHandle: id.Handle.String(),
190
+
Profile: profile,
191
+
FollowStatus: followStatus,
192
+
Followers: followers,
193
+
Following: following,
194
+
},
195
+
Strings: all,
196
+
})
197
+
}
198
+
199
+
func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
200
+
l := s.Logger.With("handler", "edit")
201
+
202
+
user := s.OAuth.GetUser(r)
203
+
204
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
205
+
if !ok {
206
+
l.Error("malformed middleware")
207
+
w.WriteHeader(http.StatusInternalServerError)
208
+
return
209
+
}
210
+
l = l.With("did", id.DID, "handle", id.Handle)
211
+
212
+
rkey := chi.URLParam(r, "rkey")
213
+
if rkey == "" {
214
+
l.Error("malformed url, empty rkey")
215
+
w.WriteHeader(http.StatusBadRequest)
216
+
return
217
+
}
218
+
l = l.With("rkey", rkey)
219
+
220
+
// get the string currently being edited
221
+
all, err := db.GetStrings(
222
+
s.Db,
223
+
db.FilterEq("did", id.DID),
224
+
db.FilterEq("rkey", rkey),
225
+
)
226
+
if err != nil {
227
+
l.Error("failed to fetch string", "err", err)
228
+
w.WriteHeader(http.StatusInternalServerError)
229
+
return
230
+
}
231
+
if len(all) != 1 {
232
+
l.Error("incorrect number of records returned", "len(strings)", len(all))
233
+
w.WriteHeader(http.StatusInternalServerError)
234
+
return
235
+
}
236
+
first := all[0]
237
+
238
+
// verify that the logged in user owns this string
239
+
if user.Did != id.DID.String() {
240
+
l.Error("unauthorized request", "expected", id.DID, "got", user.Did)
241
+
w.WriteHeader(http.StatusUnauthorized)
242
+
return
243
+
}
244
+
245
+
switch r.Method {
246
+
case http.MethodGet:
247
+
// return the form with prefilled fields
248
+
s.Pages.PutString(w, pages.PutStringParams{
249
+
LoggedInUser: s.OAuth.GetUser(r),
250
+
Action: "edit",
251
+
String: first,
252
+
})
253
+
case http.MethodPost:
254
+
fail := func(msg string, err error) {
255
+
l.Error(msg, "err", err)
256
+
s.Pages.Notice(w, "error", msg)
257
+
}
258
+
259
+
filename := r.FormValue("filename")
260
+
if filename == "" {
261
+
fail("Empty filename.", nil)
262
+
return
263
+
}
264
+
if !strings.Contains(filename, ".") {
265
+
// TODO: make this a htmx form validation
266
+
fail("No extension provided for filename.", nil)
267
+
return
268
+
}
269
+
270
+
content := r.FormValue("content")
271
+
if content == "" {
272
+
fail("Empty contents.", nil)
273
+
return
274
+
}
275
+
276
+
description := r.FormValue("description")
277
+
278
+
// construct new string from form values
279
+
entry := db.String{
280
+
Did: first.Did,
281
+
Rkey: first.Rkey,
282
+
Filename: filename,
283
+
Description: description,
284
+
Contents: content,
285
+
Created: first.Created,
286
+
}
287
+
288
+
record := entry.AsRecord()
289
+
290
+
client, err := s.OAuth.AuthorizedClient(r)
291
+
if err != nil {
292
+
fail("Failed to create record.", err)
293
+
return
294
+
}
295
+
296
+
// first replace the existing record in the PDS
297
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
298
+
if err != nil {
299
+
fail("Failed to updated existing record.", err)
300
+
return
301
+
}
302
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
303
+
Collection: tangled.StringNSID,
304
+
Repo: entry.Did.String(),
305
+
Rkey: entry.Rkey,
306
+
SwapRecord: ex.Cid,
307
+
Record: &lexutil.LexiconTypeDecoder{
308
+
Val: &record,
309
+
},
310
+
})
311
+
if err != nil {
312
+
fail("Failed to updated existing record.", err)
313
+
return
314
+
}
315
+
l := l.With("aturi", resp.Uri)
316
+
l.Info("edited string")
317
+
318
+
// if that went okay, updated the db
319
+
if err = db.AddString(s.Db, entry); err != nil {
320
+
fail("Failed to update string.", err)
321
+
return
322
+
}
323
+
324
+
// if that went okay, redir to the string
325
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
326
+
}
327
+
328
+
}
329
+
330
+
func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
331
+
l := s.Logger.With("handler", "create")
332
+
user := s.OAuth.GetUser(r)
333
+
334
+
switch r.Method {
335
+
case http.MethodGet:
336
+
s.Pages.PutString(w, pages.PutStringParams{
337
+
LoggedInUser: s.OAuth.GetUser(r),
338
+
Action: "new",
339
+
})
340
+
case http.MethodPost:
341
+
fail := func(msg string, err error) {
342
+
l.Error(msg, "err", err)
343
+
s.Pages.Notice(w, "error", msg)
344
+
}
345
+
346
+
filename := r.FormValue("filename")
347
+
if filename == "" {
348
+
fail("Empty filename.", nil)
349
+
return
350
+
}
351
+
if !strings.Contains(filename, ".") {
352
+
// TODO: make this a htmx form validation
353
+
fail("No extension provided for filename.", nil)
354
+
return
355
+
}
356
+
357
+
content := r.FormValue("content")
358
+
if content == "" {
359
+
fail("Empty contents.", nil)
360
+
return
361
+
}
362
+
363
+
description := r.FormValue("description")
364
+
365
+
string := db.String{
366
+
Did: syntax.DID(user.Did),
367
+
Rkey: tid.TID(),
368
+
Filename: filename,
369
+
Description: description,
370
+
Contents: content,
371
+
Created: time.Now(),
372
+
}
373
+
374
+
record := string.AsRecord()
375
+
376
+
client, err := s.OAuth.AuthorizedClient(r)
377
+
if err != nil {
378
+
fail("Failed to create record.", err)
379
+
return
380
+
}
381
+
382
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
383
+
Collection: tangled.StringNSID,
384
+
Repo: user.Did,
385
+
Rkey: string.Rkey,
386
+
Record: &lexutil.LexiconTypeDecoder{
387
+
Val: &record,
388
+
},
389
+
})
390
+
if err != nil {
391
+
fail("Failed to create record.", err)
392
+
return
393
+
}
394
+
l := l.With("aturi", resp.Uri)
395
+
l.Info("created record")
396
+
397
+
// insert into DB
398
+
if err = db.AddString(s.Db, string); err != nil {
399
+
fail("Failed to create string.", err)
400
+
return
401
+
}
402
+
403
+
// successful
404
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
405
+
}
406
+
}
407
+
408
+
func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
409
+
l := s.Logger.With("handler", "create")
410
+
user := s.OAuth.GetUser(r)
411
+
fail := func(msg string, err error) {
412
+
l.Error(msg, "err", err)
413
+
s.Pages.Notice(w, "error", msg)
414
+
}
415
+
416
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
417
+
if !ok {
418
+
l.Error("malformed middleware")
419
+
w.WriteHeader(http.StatusInternalServerError)
420
+
return
421
+
}
422
+
l = l.With("did", id.DID, "handle", id.Handle)
423
+
424
+
rkey := chi.URLParam(r, "rkey")
425
+
if rkey == "" {
426
+
l.Error("malformed url, empty rkey")
427
+
w.WriteHeader(http.StatusBadRequest)
428
+
return
429
+
}
430
+
431
+
if user.Did != id.DID.String() {
432
+
fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
433
+
return
434
+
}
435
+
436
+
if err := db.DeleteString(
437
+
s.Db,
438
+
db.FilterEq("did", user.Did),
439
+
db.FilterEq("rkey", rkey),
440
+
); err != nil {
441
+
fail("Failed to delete string.", err)
442
+
return
443
+
}
444
+
445
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
446
+
}
447
+
448
+
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
449
+
}
+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 }}
+56
appview/ingester.go
+56
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
}
···
549
551
550
552
return nil
551
553
}
554
+
555
+
func (i *Ingester) ingestString(e *models.Event) error {
556
+
did := e.Did
557
+
rkey := e.Commit.RKey
558
+
559
+
var err error
560
+
561
+
l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
562
+
l.Info("ingesting record")
563
+
564
+
ddb, ok := i.Db.Execer.(*db.DB)
565
+
if !ok {
566
+
return fmt.Errorf("failed to index string record, invalid db cast")
567
+
}
568
+
569
+
switch e.Commit.Operation {
570
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
571
+
raw := json.RawMessage(e.Commit.Record)
572
+
record := tangled.String{}
573
+
err = json.Unmarshal(raw, &record)
574
+
if err != nil {
575
+
l.Error("invalid record", "err", err)
576
+
return err
577
+
}
578
+
579
+
string := db.StringFromRecord(did, rkey, record)
580
+
581
+
if err = string.Validate(); err != nil {
582
+
l.Error("invalid record", "err", err)
583
+
return err
584
+
}
585
+
586
+
if err = db.AddString(ddb, string); err != nil {
587
+
l.Error("failed to add string", "err", err)
588
+
return err
589
+
}
590
+
591
+
return nil
592
+
593
+
case models.CommitOperationDelete:
594
+
if err := db.DeleteString(
595
+
ddb,
596
+
db.FilterEq("did", did),
597
+
db.FilterEq("rkey", rkey),
598
+
); err != nil {
599
+
l.Error("failed to delete", "err", err)
600
+
return fmt.Errorf("failed to delete string record: %w", err)
601
+
}
602
+
603
+
return nil
604
+
}
605
+
606
+
return nil
607
+
}
+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
···
188
178
return
189
179
}
190
180
181
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
182
+
191
183
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
192
184
if err != nil {
193
185
// invalid did or handle
+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 }}
+1
appview/state/state.go
+1
appview/state/state.go
-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
}
+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 }}
+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 }}"
-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,
···
212
204
})
213
205
}
214
206
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
-
}
222
-
223
207
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
224
208
user := s.oauth.GetUser(r)
225
209
+20
-3
appview/pages/templates/layouts/topbar.html
+20
-3
appview/pages/templates/layouts/topbar.html
···
9
9
10
10
<div id="right-items" class="flex items-center gap-4">
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>
···
21
19
</nav>
22
20
{{ end }}
23
21
22
+
{{ define "newButton" }}
23
+
<details class="relative inline-block text-left">
24
+
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
25
+
{{ i "plus" "w-4 h-4" }} new
26
+
</summary>
27
+
<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">
28
+
<a href="/repo/new" class="flex items-center gap-2">
29
+
{{ i "book-plus" "w-4 h-4" }}
30
+
new repository
31
+
</a>
32
+
<a href="/strings/new" class="flex items-center gap-2">
33
+
{{ i "line-squiggle" "w-4 h-4" }}
34
+
new string
35
+
</a>
36
+
</div>
37
+
</details>
38
+
{{ end }}
39
+
24
40
{{ define "dropDown" }}
25
41
<details class="relative inline-block text-left">
26
42
<summary
···
34
50
>
35
51
<a href="/{{ $user }}">profile</a>
36
52
<a href="/{{ $user }}?tab=repos">repositories</a>
53
+
<a href="/strings/{{ $user }}">strings</a>
37
54
<a href="/knots">knots</a>
38
55
<a href="/spindles">spindles</a>
39
56
<a href="/settings">settings</a>
+22
-22
flake.lock
+22
-22
flake.lock
···
1
1
{
2
2
"nodes": {
3
+
"flake-utils": {
4
+
"inputs": {
5
+
"systems": "systems"
6
+
},
7
+
"locked": {
8
+
"lastModified": 1694529238,
9
+
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
10
+
"owner": "numtide",
11
+
"repo": "flake-utils",
12
+
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
13
+
"type": "github"
14
+
},
15
+
"original": {
16
+
"owner": "numtide",
17
+
"repo": "flake-utils",
18
+
"type": "github"
19
+
}
20
+
},
3
21
"gitignore": {
4
22
"inputs": {
5
23
"nixpkgs": [
···
20
38
"type": "github"
21
39
}
22
40
},
23
-
"flake-utils": {
24
-
"inputs": {
25
-
"systems": "systems"
26
-
},
27
-
"locked": {
28
-
"lastModified": 1694529238,
29
-
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
30
-
"owner": "numtide",
31
-
"repo": "flake-utils",
32
-
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
33
-
"type": "github"
34
-
},
35
-
"original": {
36
-
"owner": "numtide",
37
-
"repo": "flake-utils",
38
-
"type": "github"
39
-
}
40
-
},
41
41
"gomod2nix": {
42
42
"inputs": {
43
43
"flake-utils": "flake-utils",
···
128
128
"lucide-src": {
129
129
"flake": false,
130
130
"locked": {
131
-
"lastModified": 1742302029,
132
-
"narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=",
131
+
"lastModified": 1754044466,
132
+
"narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=",
133
133
"type": "tarball",
134
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
134
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
135
135
},
136
136
"original": {
137
137
"type": "tarball",
138
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
138
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
139
139
}
140
140
},
141
141
"nixpkgs": {
+1
-1
flake.nix
+1
-1
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 = {
+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 {
+126
appview/oauth/handler/handler.go
+126
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{
···
332
338
return pubKey, nil
333
339
}
334
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
+
// TODO: hardcoded tangled handle and did for now
349
+
tangledHandle := "tangled.sh"
350
+
tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli"
351
+
352
+
if appPassword == "" {
353
+
log.Println("no app password configured, skipping spindle member addition")
354
+
return
355
+
}
356
+
357
+
log.Printf("adding %s to default spindle", did)
358
+
359
+
resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid)
360
+
if err != nil {
361
+
log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
362
+
return
363
+
}
364
+
365
+
pdsEndpoint := resolved.PDSEndpoint()
366
+
if pdsEndpoint == "" {
367
+
log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
368
+
return
369
+
}
370
+
371
+
sessionPayload := map[string]string{
372
+
"identifier": tangledHandle,
373
+
"password": appPassword,
374
+
}
375
+
sessionBytes, err := json.Marshal(sessionPayload)
376
+
if err != nil {
377
+
log.Printf("failed to marshal session payload: %v", err)
378
+
return
379
+
}
380
+
381
+
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
382
+
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
383
+
if err != nil {
384
+
log.Printf("failed to create session request: %v", err)
385
+
return
386
+
}
387
+
sessionReq.Header.Set("Content-Type", "application/json")
388
+
389
+
client := &http.Client{Timeout: 30 * time.Second}
390
+
sessionResp, err := client.Do(sessionReq)
391
+
if err != nil {
392
+
log.Printf("failed to create session: %v", err)
393
+
return
394
+
}
395
+
defer sessionResp.Body.Close()
396
+
397
+
if sessionResp.StatusCode != http.StatusOK {
398
+
log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode)
399
+
return
400
+
}
401
+
402
+
var session struct {
403
+
AccessJwt string `json:"accessJwt"`
404
+
}
405
+
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
406
+
log.Printf("failed to decode session response: %v", err)
407
+
return
408
+
}
409
+
410
+
record := tangled.SpindleMember{
411
+
LexiconTypeID: "sh.tangled.spindle.member",
412
+
Subject: did,
413
+
Instance: defaultSpindle,
414
+
CreatedAt: time.Now().Format(time.RFC3339),
415
+
}
416
+
417
+
recordBytes, err := json.Marshal(record)
418
+
if err != nil {
419
+
log.Printf("failed to marshal spindle member record: %v", err)
420
+
return
421
+
}
422
+
423
+
payload := map[string]interface{}{
424
+
"repo": tangledDid,
425
+
"collection": tangled.SpindleMemberNSID,
426
+
"rkey": tid.TID(),
427
+
"record": json.RawMessage(recordBytes),
428
+
}
429
+
430
+
payloadBytes, err := json.Marshal(payload)
431
+
if err != nil {
432
+
log.Printf("failed to marshal request payload: %v", err)
433
+
return
434
+
}
435
+
436
+
url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
437
+
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
438
+
if err != nil {
439
+
log.Printf("failed to create HTTP request: %v", err)
440
+
return
441
+
}
442
+
443
+
req.Header.Set("Content-Type", "application/json")
444
+
req.Header.Set("Authorization", "Bearer "+session.AccessJwt)
445
+
446
+
resp, err := client.Do(req)
447
+
if err != nil {
448
+
log.Printf("failed to add user to default spindle: %v", err)
449
+
return
450
+
}
451
+
defer resp.Body.Close()
452
+
453
+
if resp.StatusCode != http.StatusOK {
454
+
log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode)
455
+
return
456
+
}
457
+
458
+
log.Printf("successfully added %s to default spindle", did)
459
+
}
460
+
335
461
func (o *OAuthHandler) addToDefaultKnot(did string) {
336
462
defaultKnot := "knot1.tangled.sh"
337
463