+232
api/tangled/cbor_gen.go
+232
api/tangled/cbor_gen.go
···
8423
8424
return nil
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
+
}
+15
appview/db/db.go
+15
appview/db/db.go
···
443
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
444
);
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
+
461
create table if not exists migrations (
462
id integer primary key autoincrement,
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
+
}
+56
appview/ingester.go
+56
appview/ingester.go
···
64
err = i.ingestSpindleMember(e)
65
case tangled.SpindleNSID:
66
err = i.ingestSpindle(e)
67
+
case tangled.StringNSID:
68
+
err = i.ingestString(e)
69
}
70
l = i.Logger.With("nsid", e.Commit.Collection)
71
}
···
551
552
return nil
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
}
168
}
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
func (mw Middleware) ResolveIdent() middlewareFunc {
181
excluded := []string{"favicon.ico"}
182
···
187
next.ServeHTTP(w, req)
188
return
189
}
190
191
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
192
if err != nil {
···
167
}
168
}
169
170
func (mw Middleware) ResolveIdent() middlewareFunc {
171
excluded := []string{"favicon.ico"}
172
···
177
next.ServeHTTP(w, req)
178
return
179
}
180
+
181
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
182
183
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
184
if err != nil {
+79
-4
appview/pages/pages.go
+79
-4
appview/pages/pages.go
···
31
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
32
"github.com/alecthomas/chroma/v2/lexers"
33
"github.com/alecthomas/chroma/v2/styles"
34
"github.com/bluesky-social/indigo/atproto/syntax"
35
"github.com/go-git/go-git/v5/plumbing"
36
"github.com/go-git/go-git/v5/plumbing/object"
···
262
return p.executePlain("user/login", w, params)
263
}
264
265
-
type SignupParams struct{}
266
267
-
func (p *Pages) CompleteSignup(w io.Writer, params SignupParams) error {
268
-
return p.executePlain("user/completeSignup", w, params)
269
}
270
271
type TermsOfServiceParams struct {
···
413
UserDid string
414
UserHandle string
415
FollowStatus db.FollowStatus
416
-
AvatarUri string
417
Followers int
418
Following int
419
···
1140
func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
1141
params.Active = "pipelines"
1142
return p.executeRepo("repo/pipelines/workflow", w, params)
1143
}
1144
1145
func (p *Pages) Static() http.Handler {
···
31
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
32
"github.com/alecthomas/chroma/v2/lexers"
33
"github.com/alecthomas/chroma/v2/styles"
34
+
"github.com/bluesky-social/indigo/atproto/identity"
35
"github.com/bluesky-social/indigo/atproto/syntax"
36
"github.com/go-git/go-git/v5/plumbing"
37
"github.com/go-git/go-git/v5/plumbing/object"
···
263
return p.executePlain("user/login", w, params)
264
}
265
266
+
func (p *Pages) Signup(w io.Writer) error {
267
+
return p.executePlain("user/signup", w, nil)
268
+
}
269
270
+
func (p *Pages) CompleteSignup(w io.Writer) error {
271
+
return p.executePlain("user/completeSignup", w, nil)
272
}
273
274
type TermsOfServiceParams struct {
···
416
UserDid string
417
UserHandle string
418
FollowStatus db.FollowStatus
419
Followers int
420
Following int
421
···
1142
func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
1143
params.Active = "pipelines"
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)
1218
}
1219
1220
func (p *Pages) Static() http.Handler {
+25
-4
appview/pages/templates/layouts/topbar.html
+25
-4
appview/pages/templates/layouts/topbar.html
···
7
</a>
8
</div>
9
10
-
<div id="right-items" class="flex items-center gap-4">
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>
15
{{ block "dropDown" . }} {{ end }}
16
{{ else }}
17
<a href="/login">login</a>
18
{{ end }}
19
</div>
20
</div>
21
</nav>
22
{{ end }}
23
24
{{ define "dropDown" }}
25
<details class="relative inline-block text-left">
26
<summary
···
34
>
35
<a href="/{{ $user }}">profile</a>
36
<a href="/{{ $user }}?tab=repos">repositories</a>
37
<a href="/knots">knots</a>
38
<a href="/spindles">spindles</a>
39
<a href="/settings">settings</a>
···
7
</a>
8
</div>
9
10
+
<div id="right-items" class="flex items-center gap-2">
11
{{ with .LoggedInUser }}
12
+
{{ block "newButton" . }} {{ end }}
13
{{ block "dropDown" . }} {{ end }}
14
{{ else }}
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>
20
{{ end }}
21
</div>
22
</div>
23
</nav>
24
{{ end }}
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
+
44
{{ define "dropDown" }}
45
<details class="relative inline-block text-left">
46
<summary
···
54
>
55
<a href="/{{ $user }}">profile</a>
56
<a href="/{{ $user }}?tab=repos">repositories</a>
57
+
<a href="/strings/{{ $user }}">strings</a>
58
<a href="/knots">knots</a>
59
<a href="/spindles">spindles</a>
60
<a href="/settings">settings</a>
+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
+5
-5
appview/pages/templates/user/completeSignup.html
+5
-5
appview/pages/templates/user/completeSignup.html
···
38
tightly-knit social coding.
39
</h2>
40
<form
41
-
class="mt-4 max-w-sm mx-auto"
42
hx-post="/signup/complete"
43
hx-swap="none"
44
hx-disabled-elt="#complete-signup-button"
···
58
</span>
59
</div>
60
61
-
<div class="flex flex-col mt-4">
62
-
<label for="username">desired username</label>
63
<input
64
type="text"
65
id="username"
···
73
</span>
74
</div>
75
76
-
<div class="flex flex-col mt-4">
77
<label for="password">password</label>
78
<input
79
type="password"
···
88
</div>
89
90
<button
91
-
class="btn-create w-full my-2 mt-6"
92
type="submit"
93
id="complete-signup-button"
94
tabindex="4"
···
38
tightly-knit social coding.
39
</h2>
40
<form
41
+
class="mt-4 max-w-sm mx-auto flex flex-col gap-4"
42
hx-post="/signup/complete"
43
hx-swap="none"
44
hx-disabled-elt="#complete-signup-button"
···
58
</span>
59
</div>
60
61
+
<div class="flex flex-col">
62
+
<label for="username">username</label>
63
<input
64
type="text"
65
id="username"
···
73
</span>
74
</div>
75
76
+
<div class="flex flex-col">
77
<label for="password">password</label>
78
<input
79
type="password"
···
88
</div>
89
90
<button
91
+
class="btn-create w-full my-2 mt-6 text-base"
92
type="submit"
93
id="complete-signup-button"
94
tabindex="4"
+1
-3
appview/pages/templates/user/fragments/profileCard.html
+1
-3
appview/pages/templates/user/fragments/profileCard.html
···
2
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
5
-
{{ if .AvatarUri }}
6
<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 }}" />
8
</div>
9
-
{{ end }}
10
</div>
11
<div class="col-span-2">
12
<p title="{{ didOrHandle .UserDid .UserHandle }}"
···
2
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
5
<div class="w-3/4 aspect-square relative">
6
+
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" />
7
</div>
8
</div>
9
<div class="col-span-2">
10
<p title="{{ didOrHandle .UserDid .UserHandle }}"
+11
-79
appview/pages/templates/user/login.html
+11
-79
appview/pages/templates/user/login.html
···
3
<html lang="en" class="dark:bg-gray-900">
4
<head>
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
-
/>
22
<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>
29
</head>
30
<body class="flex items-center justify-center min-h-screen">
31
<main class="max-w-md px-6 -mt-4">
32
-
<h1
33
-
class="text-center text-2xl font-semibold italic dark:text-white"
34
-
>
35
tangled
36
</h1>
37
<h2 class="text-center text-xl italic dark:text-white">
···
51
name="handle"
52
tabindex="1"
53
required
54
-
placeholder="foo.tngl.sh"
55
/>
56
<span class="text-sm text-gray-500 mt-1">
57
Use your <a href="https://atproto.com">ATProto</a>
···
61
</div>
62
63
<button
64
-
class="btn w-full my-2 mt-6"
65
type="submit"
66
id="login-button"
67
tabindex="3"
···
69
<span>login</span>
70
</button>
71
</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.
76
</p>
77
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
<p id="login-msg" class="error w-full"></p>
127
</main>
128
</body>
···
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="login ยท tangled" />
8
+
<meta property="og:url" content="https://tangled.sh/login" />
9
+
<meta property="og:description" content="login to for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
+
<title>login · 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" >
17
tangled
18
</h1>
19
<h2 class="text-center text-xl italic dark:text-white">
···
33
name="handle"
34
tabindex="1"
35
required
36
+
placeholder="akshay.tngl.sh"
37
/>
38
<span class="text-sm text-gray-500 mt-1">
39
Use your <a href="https://atproto.com">ATProto</a>
···
43
</div>
44
45
<button
46
+
class="btn w-full my-2 mt-6 text-base "
47
type="submit"
48
id="login-button"
49
tabindex="3"
···
51
<span>login</span>
52
</button>
53
</form>
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!
56
</p>
57
58
<p id="login-msg" class="error w-full"></p>
59
</main>
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
return
743
}
744
745
-
// remove a single leading `@`, to make @handle work with ResolveIdent
746
-
collaborator = strings.TrimPrefix(collaborator, "@")
747
-
748
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
749
if err != nil {
750
fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
+56
-49
appview/signup/signup.go
+56
-49
appview/signup/signup.go
···
104
105
func (s *Signup) Router() http.Handler {
106
r := chi.NewRouter()
107
r.Post("/", s.signup)
108
r.Get("/complete", s.complete)
109
r.Post("/complete", s.complete)
···
112
}
113
114
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")
119
120
-
if !email.IsValidEmail(emailId) {
121
-
s.pages.Notice(w, "login-msg", "Invalid email address.")
122
-
return
123
-
}
124
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
-
}
135
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
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
` + code,
150
-
Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
151
<p><code>` + code + `</code></p>`,
152
-
}
153
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
159
-
}
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
}
172
173
func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
174
switch r.Method {
175
case http.MethodGet:
176
-
s.pages.CompleteSignup(w, pages.SignupParams{})
177
case http.MethodPost:
178
username := r.FormValue("username")
179
password := r.FormValue("password")
···
104
105
func (s *Signup) Router() http.Handler {
106
r := chi.NewRouter()
107
+
r.Get("/", s.signup)
108
r.Post("/", s.signup)
109
r.Get("/complete", s.complete)
110
r.Post("/complete", s.complete)
···
113
}
114
115
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
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")
124
125
+
noticeId := "signup-msg"
126
+
if !email.IsValidEmail(emailId) {
127
+
s.pages.Notice(w, noticeId, "Invalid email address.")
128
+
return
129
+
}
130
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
+
}
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
+
}
148
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.
155
` + code,
156
+
Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
157
<p><code>` + code + `</code></p>`,
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
+
}
175
176
+
s.pages.HxRedirect(w, "/signup/complete")
177
+
}
178
}
179
180
func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
181
switch r.Method {
182
case http.MethodGet:
183
+
s.pages.CompleteSignup(w)
184
case http.MethodPost:
185
username := r.FormValue("username")
186
password := r.FormValue("password")
-16
appview/state/profile.go
-16
appview/state/profile.go
···
1
package state
2
3
import (
4
-
"crypto/hmac"
5
-
"crypto/sha256"
6
-
"encoding/hex"
7
"fmt"
8
"log"
9
"net/http"
···
142
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
143
}
144
145
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
146
s.pages.ProfilePage(w, pages.ProfilePageParams{
147
LoggedInUser: loggedInUser,
148
Repos: pinnedRepos,
···
151
Card: pages.ProfileCard{
152
UserDid: ident.DID.String(),
153
UserHandle: ident.Handle.String(),
154
-
AvatarUri: profileAvatarUri,
155
Profile: profile,
156
FollowStatus: followStatus,
157
Followers: followers,
···
194
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
195
}
196
197
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
198
-
199
s.pages.ReposPage(w, pages.ReposPageParams{
200
LoggedInUser: loggedInUser,
201
Repos: repos,
···
203
Card: pages.ProfileCard{
204
UserDid: ident.DID.String(),
205
UserHandle: ident.Handle.String(),
206
-
AvatarUri: profileAvatarUri,
207
Profile: profile,
208
FollowStatus: followStatus,
209
Followers: followers,
210
Following: following,
211
},
212
})
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
}
222
223
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
···
1
package state
2
3
import (
4
"fmt"
5
"log"
6
"net/http"
···
139
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
140
}
141
142
s.pages.ProfilePage(w, pages.ProfilePageParams{
143
LoggedInUser: loggedInUser,
144
Repos: pinnedRepos,
···
147
Card: pages.ProfileCard{
148
UserDid: ident.DID.String(),
149
UserHandle: ident.Handle.String(),
150
Profile: profile,
151
FollowStatus: followStatus,
152
Followers: followers,
···
189
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
190
}
191
192
s.pages.ReposPage(w, pages.ReposPageParams{
193
LoggedInUser: loggedInUser,
194
Repos: repos,
···
196
Card: pages.ProfileCard{
197
UserDid: ident.DID.String(),
198
UserHandle: ident.Handle.String(),
199
Profile: profile,
200
FollowStatus: followStatus,
201
Followers: followers,
202
Following: following,
203
},
204
})
205
}
206
207
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
+19
-3
appview/state/router.go
+19
-3
appview/state/router.go
···
17
"tangled.sh/tangled.sh/core/appview/signup"
18
"tangled.sh/tangled.sh/core/appview/spindles"
19
"tangled.sh/tangled.sh/core/appview/state/userutil"
20
"tangled.sh/tangled.sh/core/log"
21
)
22
···
67
func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
68
r := chi.NewRouter()
69
70
-
// strip @ from user
71
-
r.Use(middleware.StripLeadingAt)
72
-
73
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
74
r.Get("/", s.Profile)
75
···
136
})
137
138
r.Mount("/settings", s.SettingsRouter())
139
r.Mount("/knots", s.KnotsRouter(mw))
140
r.Mount("/spindles", s.SpindlesRouter())
141
r.Mount("/signup", s.SignupRouter())
···
199
}
200
201
return knots.Router(mw)
202
}
203
204
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
···
17
"tangled.sh/tangled.sh/core/appview/signup"
18
"tangled.sh/tangled.sh/core/appview/spindles"
19
"tangled.sh/tangled.sh/core/appview/state/userutil"
20
+
avstrings "tangled.sh/tangled.sh/core/appview/strings"
21
"tangled.sh/tangled.sh/core/log"
22
)
23
···
68
func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
69
r := chi.NewRouter()
70
71
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
72
r.Get("/", s.Profile)
73
···
134
})
135
136
r.Mount("/settings", s.SettingsRouter())
137
+
r.Mount("/strings", s.StringsRouter(mw))
138
r.Mount("/knots", s.KnotsRouter(mw))
139
r.Mount("/spindles", s.SpindlesRouter())
140
r.Mount("/signup", s.SignupRouter())
···
198
}
199
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)
218
}
219
220
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
+1
appview/state/state.go
+1
appview/state/state.go
+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
+
}
+1
cmd/gen.go
+1
cmd/gen.go
+22
-22
flake.lock
+22
-22
flake.lock
···
1
{
2
"nodes": {
3
"gitignore": {
4
"inputs": {
5
"nixpkgs": [
···
17
"original": {
18
"owner": "hercules-ci",
19
"repo": "gitignore.nix",
20
-
"type": "github"
21
-
}
22
-
},
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
},
···
128
"lucide-src": {
129
"flake": false,
130
"locked": {
131
-
"lastModified": 1742302029,
132
-
"narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=",
133
"type": "tarball",
134
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
135
},
136
"original": {
137
"type": "tarball",
138
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
139
}
140
},
141
"nixpkgs": {
···
1
{
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
+
},
21
"gitignore": {
22
"inputs": {
23
"nixpkgs": [
···
35
"original": {
36
"owner": "hercules-ci",
37
"repo": "gitignore.nix",
38
"type": "github"
39
}
40
},
···
128
"lucide-src": {
129
"flake": false,
130
"locked": {
131
+
"lastModified": 1754044466,
132
+
"narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=",
133
"type": "tarball",
134
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
135
},
136
"original": {
137
"type": "tarball",
138
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
139
}
140
},
141
"nixpkgs": {
+1
-1
flake.nix
+1
-1
flake.nix
-8
knotserver/file.go
-8
knotserver/file.go
···
10
"tangled.sh/tangled.sh/core/types"
11
)
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
func countLines(r io.Reader) (int, error) {
21
buf := make([]byte, 32*1024)
22
bufLen := 0
···
52
53
resp.Lines = lc
54
writeJSON(w, resp)
55
-
return
56
}
+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
+
}