+1
-1
.air/appview.toml
+1
-1
.air/appview.toml
+1
-1
.air/knotserver.toml
+1
-1
.air/knotserver.toml
+7
.gitignore
+7
.gitignore
+31
api/tangled/actorprofile.go
+31
api/tangled/actorprofile.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.actor.profile
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
ActorProfileNSID = "sh.tangled.actor.profile"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.actor.profile", &ActorProfile{})
17
+
} //
18
+
// RECORDTYPE: ActorProfile
19
+
type ActorProfile struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.actor.profile" cborgen:"$type,const=sh.tangled.actor.profile"`
21
+
// bluesky: Include link to this account on Bluesky.
22
+
Bluesky bool `json:"bluesky" cborgen:"bluesky"`
23
+
// description: Free-form profile description text.
24
+
Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
25
+
Links []string `json:"links,omitempty" cborgen:"links,omitempty"`
26
+
// location: Free-form location text.
27
+
Location *string `json:"location,omitempty" cborgen:"location,omitempty"`
28
+
// pinnedRepositories: Any ATURI, it is up to appviews to validate these fields.
29
+
PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"`
30
+
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
31
+
}
+1193
-449
api/tangled/cbor_gen.go
+1193
-449
api/tangled/cbor_gen.go
···
8
"math"
9
"sort"
10
11
cid "github.com/ipfs/go-cid"
12
cbg "github.com/whyrusleeping/cbor-gen"
13
xerrors "golang.org/x/xerrors"
···
353
}
354
355
cw := cbg.NewCborWriter(w)
356
-
fieldCount := 4
357
358
-
if t.AddedAt == nil {
359
-
fieldCount--
360
-
}
361
-
362
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
363
return err
364
}
365
···
405
return err
406
}
407
408
-
// t.Member (string) (string)
409
-
if len("member") > 1000000 {
410
-
return xerrors.Errorf("Value in field \"member\" was too long")
411
}
412
413
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("member"))); err != nil {
414
return err
415
}
416
-
if _, err := cw.WriteString(string("member")); err != nil {
417
return err
418
}
419
420
-
if len(t.Member) > 1000000 {
421
-
return xerrors.Errorf("Value in field t.Member was too long")
422
}
423
424
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Member))); err != nil {
425
return err
426
}
427
-
if _, err := cw.WriteString(string(t.Member)); err != nil {
428
return err
429
}
430
431
-
// t.AddedAt (string) (string)
432
-
if t.AddedAt != nil {
433
434
-
if len("addedAt") > 1000000 {
435
-
return xerrors.Errorf("Value in field \"addedAt\" was too long")
436
-
}
437
438
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("addedAt"))); err != nil {
439
-
return err
440
-
}
441
-
if _, err := cw.WriteString(string("addedAt")); err != nil {
442
-
return err
443
-
}
444
-
445
-
if t.AddedAt == nil {
446
-
if _, err := cw.Write(cbg.CborNull); err != nil {
447
-
return err
448
-
}
449
-
} else {
450
-
if len(*t.AddedAt) > 1000000 {
451
-
return xerrors.Errorf("Value in field t.AddedAt was too long")
452
-
}
453
454
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.AddedAt))); err != nil {
455
-
return err
456
-
}
457
-
if _, err := cw.WriteString(string(*t.AddedAt)); err != nil {
458
-
return err
459
-
}
460
-
}
461
}
462
return nil
463
}
···
487
488
n := extra
489
490
-
nameBuf := make([]byte, 7)
491
for i := uint64(0); i < n; i++ {
492
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
493
if err != nil {
···
525
526
t.Domain = string(sval)
527
}
528
-
// t.Member (string) (string)
529
-
case "member":
530
531
{
532
sval, err := cbg.ReadStringWithMax(cr, 1000000)
···
534
return err
535
}
536
537
-
t.Member = string(sval)
538
}
539
-
// t.AddedAt (string) (string)
540
-
case "addedAt":
541
542
{
543
-
b, err := cr.ReadByte()
544
if err != nil {
545
return err
546
}
547
-
if b != cbg.CborNull[0] {
548
-
if err := cr.UnreadByte(); err != nil {
549
-
return err
550
-
}
551
552
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
553
-
if err != nil {
554
-
return err
555
-
}
556
-
557
-
t.AddedAt = (*string)(&sval)
558
-
}
559
}
560
561
default:
···
645
return err
646
}
647
648
-
// t.Created (string) (string)
649
-
if len("created") > 1000000 {
650
-
return xerrors.Errorf("Value in field \"created\" was too long")
651
}
652
653
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("created"))); err != nil {
654
return err
655
}
656
-
if _, err := cw.WriteString(string("created")); err != nil {
657
return err
658
}
659
660
-
if len(t.Created) > 1000000 {
661
-
return xerrors.Errorf("Value in field t.Created was too long")
662
}
663
664
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Created))); err != nil {
665
return err
666
}
667
-
if _, err := cw.WriteString(string(t.Created)); err != nil {
668
return err
669
}
670
return nil
···
695
696
n := extra
697
698
-
nameBuf := make([]byte, 7)
699
for i := uint64(0); i < n; i++ {
700
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
701
if err != nil {
···
744
745
t.LexiconTypeID = string(sval)
746
}
747
-
// t.Created (string) (string)
748
-
case "created":
749
750
{
751
sval, err := cbg.ReadStringWithMax(cr, 1000000)
···
753
return err
754
}
755
756
-
t.Created = string(sval)
757
}
758
759
default:
···
775
cw := cbg.NewCborWriter(w)
776
fieldCount := 7
777
778
-
if t.Body == nil {
779
-
fieldCount--
780
-
}
781
-
782
if t.CommentId == nil {
783
fieldCount--
784
}
785
786
-
if t.CreatedAt == nil {
787
-
fieldCount--
788
-
}
789
-
790
if t.Owner == nil {
791
fieldCount--
792
}
···
800
}
801
802
// t.Body (string) (string)
803
-
if t.Body != nil {
804
805
-
if len("body") > 1000000 {
806
-
return xerrors.Errorf("Value in field \"body\" was too long")
807
-
}
808
809
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil {
810
-
return err
811
-
}
812
-
if _, err := cw.WriteString(string("body")); err != nil {
813
-
return err
814
-
}
815
816
-
if t.Body == nil {
817
-
if _, err := cw.Write(cbg.CborNull); err != nil {
818
-
return err
819
-
}
820
-
} else {
821
-
if len(*t.Body) > 1000000 {
822
-
return xerrors.Errorf("Value in field t.Body was too long")
823
-
}
824
-
825
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Body))); err != nil {
826
-
return err
827
-
}
828
-
if _, err := cw.WriteString(string(*t.Body)); err != nil {
829
-
return err
830
-
}
831
-
}
832
}
833
834
// t.Repo (string) (string)
···
970
}
971
972
// t.CreatedAt (string) (string)
973
-
if t.CreatedAt != nil {
974
975
-
if len("createdAt") > 1000000 {
976
-
return xerrors.Errorf("Value in field \"createdAt\" was too long")
977
-
}
978
979
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
980
-
return err
981
-
}
982
-
if _, err := cw.WriteString(string("createdAt")); err != nil {
983
-
return err
984
-
}
985
986
-
if t.CreatedAt == nil {
987
-
if _, err := cw.Write(cbg.CborNull); err != nil {
988
-
return err
989
-
}
990
-
} else {
991
-
if len(*t.CreatedAt) > 1000000 {
992
-
return xerrors.Errorf("Value in field t.CreatedAt was too long")
993
-
}
994
-
995
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreatedAt))); err != nil {
996
-
return err
997
-
}
998
-
if _, err := cw.WriteString(string(*t.CreatedAt)); err != nil {
999
-
return err
1000
-
}
1001
-
}
1002
}
1003
return nil
1004
}
···
1048
case "body":
1049
1050
{
1051
-
b, err := cr.ReadByte()
1052
if err != nil {
1053
return err
1054
}
1055
-
if b != cbg.CborNull[0] {
1056
-
if err := cr.UnreadByte(); err != nil {
1057
-
return err
1058
-
}
1059
1060
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1061
-
if err != nil {
1062
-
return err
1063
-
}
1064
-
1065
-
t.Body = (*string)(&sval)
1066
-
}
1067
}
1068
// t.Repo (string) (string)
1069
case "repo":
···
1169
case "createdAt":
1170
1171
{
1172
-
b, err := cr.ReadByte()
1173
if err != nil {
1174
return err
1175
}
1176
-
if b != cbg.CborNull[0] {
1177
-
if err := cr.UnreadByte(); err != nil {
1178
-
return err
1179
-
}
1180
-
1181
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1182
-
if err != nil {
1183
-
return err
1184
-
}
1185
1186
-
t.CreatedAt = (*string)(&sval)
1187
-
}
1188
}
1189
1190
default:
···
1204
}
1205
1206
cw := cbg.NewCborWriter(w)
1207
-
fieldCount := 3
1208
1209
-
if t.State == nil {
1210
-
fieldCount--
1211
-
}
1212
-
1213
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1214
return err
1215
}
1216
···
1257
}
1258
1259
// t.State (string) (string)
1260
-
if t.State != nil {
1261
1262
-
if len("state") > 1000000 {
1263
-
return xerrors.Errorf("Value in field \"state\" was too long")
1264
-
}
1265
1266
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("state"))); err != nil {
1267
-
return err
1268
-
}
1269
-
if _, err := cw.WriteString(string("state")); err != nil {
1270
-
return err
1271
-
}
1272
1273
-
if t.State == nil {
1274
-
if _, err := cw.Write(cbg.CborNull); err != nil {
1275
-
return err
1276
-
}
1277
-
} else {
1278
-
if len(*t.State) > 1000000 {
1279
-
return xerrors.Errorf("Value in field t.State was too long")
1280
-
}
1281
-
1282
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.State))); err != nil {
1283
-
return err
1284
-
}
1285
-
if _, err := cw.WriteString(string(*t.State)); err != nil {
1286
-
return err
1287
-
}
1288
-
}
1289
}
1290
return nil
1291
}
···
1357
case "state":
1358
1359
{
1360
-
b, err := cr.ReadByte()
1361
if err != nil {
1362
return err
1363
}
1364
-
if b != cbg.CborNull[0] {
1365
-
if err := cr.UnreadByte(); err != nil {
1366
-
return err
1367
-
}
1368
1369
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1370
-
if err != nil {
1371
-
return err
1372
-
}
1373
-
1374
-
t.State = (*string)(&sval)
1375
-
}
1376
}
1377
1378
default:
···
1398
fieldCount--
1399
}
1400
1401
-
if t.CreatedAt == nil {
1402
-
fieldCount--
1403
-
}
1404
-
1405
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1406
return err
1407
}
···
1549
}
1550
1551
// t.CreatedAt (string) (string)
1552
-
if t.CreatedAt != nil {
1553
1554
-
if len("createdAt") > 1000000 {
1555
-
return xerrors.Errorf("Value in field \"createdAt\" was too long")
1556
-
}
1557
1558
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
1559
-
return err
1560
-
}
1561
-
if _, err := cw.WriteString(string("createdAt")); err != nil {
1562
-
return err
1563
-
}
1564
1565
-
if t.CreatedAt == nil {
1566
-
if _, err := cw.Write(cbg.CborNull); err != nil {
1567
-
return err
1568
-
}
1569
-
} else {
1570
-
if len(*t.CreatedAt) > 1000000 {
1571
-
return xerrors.Errorf("Value in field t.CreatedAt was too long")
1572
-
}
1573
-
1574
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreatedAt))); err != nil {
1575
-
return err
1576
-
}
1577
-
if _, err := cw.WriteString(string(*t.CreatedAt)); err != nil {
1578
-
return err
1579
-
}
1580
-
}
1581
}
1582
return nil
1583
}
···
1718
case "createdAt":
1719
1720
{
1721
-
b, err := cr.ReadByte()
1722
if err != nil {
1723
return err
1724
}
1725
-
if b != cbg.CborNull[0] {
1726
-
if err := cr.UnreadByte(); err != nil {
1727
-
return err
1728
-
}
1729
1730
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1731
-
if err != nil {
1732
-
return err
1733
-
}
1734
-
1735
-
t.CreatedAt = (*string)(&sval)
1736
-
}
1737
}
1738
1739
default:
···
1753
}
1754
1755
cw := cbg.NewCborWriter(w)
1756
-
fieldCount := 6
1757
1758
-
if t.AddedAt == nil {
1759
fieldCount--
1760
}
1761
1762
-
if t.Description == nil {
1763
fieldCount--
1764
}
1765
···
1855
return err
1856
}
1857
1858
-
// t.AddedAt (string) (string)
1859
-
if t.AddedAt != nil {
1860
1861
-
if len("addedAt") > 1000000 {
1862
-
return xerrors.Errorf("Value in field \"addedAt\" was too long")
1863
}
1864
1865
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("addedAt"))); err != nil {
1866
return err
1867
}
1868
-
if _, err := cw.WriteString(string("addedAt")); err != nil {
1869
return err
1870
}
1871
1872
-
if t.AddedAt == nil {
1873
if _, err := cw.Write(cbg.CborNull); err != nil {
1874
return err
1875
}
1876
} else {
1877
-
if len(*t.AddedAt) > 1000000 {
1878
-
return xerrors.Errorf("Value in field t.AddedAt was too long")
1879
}
1880
1881
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.AddedAt))); err != nil {
1882
return err
1883
}
1884
-
if _, err := cw.WriteString(string(*t.AddedAt)); err != nil {
1885
return err
1886
}
1887
}
1888
}
1889
1890
// t.Description (string) (string)
1891
if t.Description != nil {
1892
···
2006
2007
t.Owner = string(sval)
2008
}
2009
-
// t.AddedAt (string) (string)
2010
-
case "addedAt":
2011
2012
{
2013
b, err := cr.ReadByte()
···
2024
return err
2025
}
2026
2027
-
t.AddedAt = (*string)(&sval)
2028
}
2029
}
2030
// t.Description (string) (string)
2031
case "description":
2032
···
2072
fieldCount--
2073
}
2074
2075
-
if t.CreatedAt == nil {
2076
-
fieldCount--
2077
-
}
2078
-
2079
-
if t.SourceRepo == nil {
2080
fieldCount--
2081
}
2082
···
2203
}
2204
}
2205
2206
-
// t.CreatedAt (string) (string)
2207
-
if t.CreatedAt != nil {
2208
2209
-
if len("createdAt") > 1000000 {
2210
-
return xerrors.Errorf("Value in field \"createdAt\" was too long")
2211
}
2212
2213
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
2214
return err
2215
}
2216
-
if _, err := cw.WriteString(string("createdAt")); err != nil {
2217
return err
2218
}
2219
2220
-
if t.CreatedAt == nil {
2221
-
if _, err := cw.Write(cbg.CborNull); err != nil {
2222
-
return err
2223
-
}
2224
-
} else {
2225
-
if len(*t.CreatedAt) > 1000000 {
2226
-
return xerrors.Errorf("Value in field t.CreatedAt was too long")
2227
-
}
2228
-
2229
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreatedAt))); err != nil {
2230
-
return err
2231
-
}
2232
-
if _, err := cw.WriteString(string(*t.CreatedAt)); err != nil {
2233
-
return err
2234
-
}
2235
}
2236
}
2237
2238
-
// t.SourceRepo (string) (string)
2239
-
if t.SourceRepo != nil {
2240
2241
-
if len("sourceRepo") > 1000000 {
2242
-
return xerrors.Errorf("Value in field \"sourceRepo\" was too long")
2243
-
}
2244
2245
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sourceRepo"))); err != nil {
2246
-
return err
2247
-
}
2248
-
if _, err := cw.WriteString(string("sourceRepo")); err != nil {
2249
-
return err
2250
-
}
2251
2252
-
if t.SourceRepo == nil {
2253
-
if _, err := cw.Write(cbg.CborNull); err != nil {
2254
-
return err
2255
-
}
2256
-
} else {
2257
-
if len(*t.SourceRepo) > 1000000 {
2258
-
return xerrors.Errorf("Value in field t.SourceRepo was too long")
2259
-
}
2260
-
2261
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.SourceRepo))); err != nil {
2262
-
return err
2263
-
}
2264
-
if _, err := cw.WriteString(string(*t.SourceRepo)); err != nil {
2265
-
return err
2266
-
}
2267
-
}
2268
}
2269
2270
// t.TargetRepo (string) (string)
···
2436
2437
t.PullId = int64(extraI)
2438
}
2439
-
// t.CreatedAt (string) (string)
2440
-
case "createdAt":
2441
2442
{
2443
b, err := cr.ReadByte()
2444
if err != nil {
2445
return err
···
2448
if err := cr.UnreadByte(); err != nil {
2449
return err
2450
}
2451
-
2452
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2453
-
if err != nil {
2454
-
return err
2455
}
2456
2457
-
t.CreatedAt = (*string)(&sval)
2458
-
}
2459
}
2460
-
// t.SourceRepo (string) (string)
2461
-
case "sourceRepo":
2462
2463
{
2464
-
b, err := cr.ReadByte()
2465
if err != nil {
2466
return err
2467
}
2468
-
if b != cbg.CborNull[0] {
2469
-
if err := cr.UnreadByte(); err != nil {
2470
-
return err
2471
-
}
2472
2473
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2474
-
if err != nil {
2475
-
return err
2476
-
}
2477
-
2478
-
t.SourceRepo = (*string)(&sval)
2479
-
}
2480
}
2481
// t.TargetRepo (string) (string)
2482
case "targetRepo":
···
2511
2512
return nil
2513
}
2514
-
func (t *RepoPullStatus) MarshalCBOR(w io.Writer) error {
2515
if t == nil {
2516
_, err := w.Write(cbg.CborNull)
2517
return err
2518
}
2519
2520
cw := cbg.NewCborWriter(w)
2521
-
fieldCount := 3
2522
2523
-
if t.Status == nil {
2524
fieldCount--
2525
}
2526
···
2528
return err
2529
}
2530
2531
// t.Pull (string) (string)
2532
if len("pull") > 1000000 {
2533
return xerrors.Errorf("Value in field \"pull\" was too long")
···
2571
}
2572
2573
// t.Status (string) (string)
2574
-
if t.Status != nil {
2575
2576
-
if len("status") > 1000000 {
2577
-
return xerrors.Errorf("Value in field \"status\" was too long")
2578
-
}
2579
2580
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("status"))); err != nil {
2581
-
return err
2582
-
}
2583
-
if _, err := cw.WriteString(string("status")); err != nil {
2584
-
return err
2585
-
}
2586
-
2587
-
if t.Status == nil {
2588
-
if _, err := cw.Write(cbg.CborNull); err != nil {
2589
-
return err
2590
-
}
2591
-
} else {
2592
-
if len(*t.Status) > 1000000 {
2593
-
return xerrors.Errorf("Value in field t.Status was too long")
2594
-
}
2595
2596
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Status))); err != nil {
2597
-
return err
2598
-
}
2599
-
if _, err := cw.WriteString(string(*t.Status)); err != nil {
2600
-
return err
2601
-
}
2602
-
}
2603
}
2604
return nil
2605
}
···
2671
case "status":
2672
2673
{
2674
-
b, err := cr.ReadByte()
2675
if err != nil {
2676
return err
2677
}
2678
-
if b != cbg.CborNull[0] {
2679
-
if err := cr.UnreadByte(); err != nil {
2680
-
return err
2681
-
}
2682
2683
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2684
-
if err != nil {
2685
-
return err
2686
-
}
2687
-
2688
-
t.Status = (*string)(&sval)
2689
-
}
2690
}
2691
2692
default:
···
2708
cw := cbg.NewCborWriter(w)
2709
fieldCount := 7
2710
2711
-
if t.Body == nil {
2712
-
fieldCount--
2713
-
}
2714
-
2715
if t.CommentId == nil {
2716
-
fieldCount--
2717
-
}
2718
-
2719
-
if t.CreatedAt == nil {
2720
fieldCount--
2721
}
2722
···
2733
}
2734
2735
// t.Body (string) (string)
2736
-
if t.Body != nil {
2737
-
2738
-
if len("body") > 1000000 {
2739
-
return xerrors.Errorf("Value in field \"body\" was too long")
2740
-
}
2741
2742
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil {
2743
-
return err
2744
-
}
2745
-
if _, err := cw.WriteString(string("body")); err != nil {
2746
-
return err
2747
-
}
2748
2749
-
if t.Body == nil {
2750
-
if _, err := cw.Write(cbg.CborNull); err != nil {
2751
-
return err
2752
-
}
2753
-
} else {
2754
-
if len(*t.Body) > 1000000 {
2755
-
return xerrors.Errorf("Value in field t.Body was too long")
2756
-
}
2757
2758
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Body))); err != nil {
2759
-
return err
2760
-
}
2761
-
if _, err := cw.WriteString(string(*t.Body)); err != nil {
2762
-
return err
2763
-
}
2764
-
}
2765
}
2766
2767
// t.Pull (string) (string)
···
2903
}
2904
2905
// t.CreatedAt (string) (string)
2906
-
if t.CreatedAt != nil {
2907
-
2908
-
if len("createdAt") > 1000000 {
2909
-
return xerrors.Errorf("Value in field \"createdAt\" was too long")
2910
-
}
2911
2912
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
2913
-
return err
2914
-
}
2915
-
if _, err := cw.WriteString(string("createdAt")); err != nil {
2916
-
return err
2917
-
}
2918
2919
-
if t.CreatedAt == nil {
2920
-
if _, err := cw.Write(cbg.CborNull); err != nil {
2921
-
return err
2922
-
}
2923
-
} else {
2924
-
if len(*t.CreatedAt) > 1000000 {
2925
-
return xerrors.Errorf("Value in field t.CreatedAt was too long")
2926
-
}
2927
2928
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreatedAt))); err != nil {
2929
-
return err
2930
-
}
2931
-
if _, err := cw.WriteString(string(*t.CreatedAt)); err != nil {
2932
-
return err
2933
-
}
2934
-
}
2935
}
2936
return nil
2937
}
···
2981
case "body":
2982
2983
{
2984
-
b, err := cr.ReadByte()
2985
if err != nil {
2986
return err
2987
}
2988
-
if b != cbg.CborNull[0] {
2989
-
if err := cr.UnreadByte(); err != nil {
2990
-
return err
2991
-
}
2992
2993
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2994
-
if err != nil {
2995
-
return err
2996
-
}
2997
-
2998
-
t.Body = (*string)(&sval)
2999
-
}
3000
}
3001
// t.Pull (string) (string)
3002
case "pull":
···
3102
case "createdAt":
3103
3104
{
3105
b, err := cr.ReadByte()
3106
if err != nil {
3107
return err
···
3116
return err
3117
}
3118
3119
-
t.CreatedAt = (*string)(&sval)
3120
}
3121
}
3122
···
8
"math"
9
"sort"
10
11
+
util "github.com/bluesky-social/indigo/lex/util"
12
cid "github.com/ipfs/go-cid"
13
cbg "github.com/whyrusleeping/cbor-gen"
14
xerrors "golang.org/x/xerrors"
···
354
}
355
356
cw := cbg.NewCborWriter(w)
357
358
+
if _, err := cw.Write([]byte{164}); err != nil {
359
return err
360
}
361
···
401
return err
402
}
403
404
+
// t.Subject (string) (string)
405
+
if len("subject") > 1000000 {
406
+
return xerrors.Errorf("Value in field \"subject\" was too long")
407
}
408
409
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
410
return err
411
}
412
+
if _, err := cw.WriteString(string("subject")); err != nil {
413
return err
414
}
415
416
+
if len(t.Subject) > 1000000 {
417
+
return xerrors.Errorf("Value in field t.Subject was too long")
418
}
419
420
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
421
return err
422
}
423
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
424
return err
425
}
426
427
+
// t.CreatedAt (string) (string)
428
+
if len("createdAt") > 1000000 {
429
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
430
+
}
431
432
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
433
+
return err
434
+
}
435
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
436
+
return err
437
+
}
438
439
+
if len(t.CreatedAt) > 1000000 {
440
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
441
+
}
442
443
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
444
+
return err
445
+
}
446
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
447
+
return err
448
}
449
return nil
450
}
···
474
475
n := extra
476
477
+
nameBuf := make([]byte, 9)
478
for i := uint64(0); i < n; i++ {
479
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
480
if err != nil {
···
512
513
t.Domain = string(sval)
514
}
515
+
// t.Subject (string) (string)
516
+
case "subject":
517
518
{
519
sval, err := cbg.ReadStringWithMax(cr, 1000000)
···
521
return err
522
}
523
524
+
t.Subject = string(sval)
525
}
526
+
// t.CreatedAt (string) (string)
527
+
case "createdAt":
528
529
{
530
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
531
if err != nil {
532
return err
533
}
534
535
+
t.CreatedAt = string(sval)
536
}
537
538
default:
···
622
return err
623
}
624
625
+
// t.CreatedAt (string) (string)
626
+
if len("createdAt") > 1000000 {
627
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
628
}
629
630
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
631
return err
632
}
633
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
634
return err
635
}
636
637
+
if len(t.CreatedAt) > 1000000 {
638
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
639
}
640
641
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
642
return err
643
}
644
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
645
return err
646
}
647
return nil
···
672
673
n := extra
674
675
+
nameBuf := make([]byte, 9)
676
for i := uint64(0); i < n; i++ {
677
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
678
if err != nil {
···
721
722
t.LexiconTypeID = string(sval)
723
}
724
+
// t.CreatedAt (string) (string)
725
+
case "createdAt":
726
727
{
728
sval, err := cbg.ReadStringWithMax(cr, 1000000)
···
730
return err
731
}
732
733
+
t.CreatedAt = string(sval)
734
}
735
736
default:
···
752
cw := cbg.NewCborWriter(w)
753
fieldCount := 7
754
755
if t.CommentId == nil {
756
fieldCount--
757
}
758
759
if t.Owner == nil {
760
fieldCount--
761
}
···
769
}
770
771
// t.Body (string) (string)
772
+
if len("body") > 1000000 {
773
+
return xerrors.Errorf("Value in field \"body\" was too long")
774
+
}
775
776
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil {
777
+
return err
778
+
}
779
+
if _, err := cw.WriteString(string("body")); err != nil {
780
+
return err
781
+
}
782
783
+
if len(t.Body) > 1000000 {
784
+
return xerrors.Errorf("Value in field t.Body was too long")
785
+
}
786
787
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil {
788
+
return err
789
+
}
790
+
if _, err := cw.WriteString(string(t.Body)); err != nil {
791
+
return err
792
}
793
794
// t.Repo (string) (string)
···
930
}
931
932
// t.CreatedAt (string) (string)
933
+
if len("createdAt") > 1000000 {
934
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
935
+
}
936
937
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
938
+
return err
939
+
}
940
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
941
+
return err
942
+
}
943
944
+
if len(t.CreatedAt) > 1000000 {
945
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
946
+
}
947
948
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
949
+
return err
950
+
}
951
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
952
+
return err
953
}
954
return nil
955
}
···
999
case "body":
1000
1001
{
1002
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1003
if err != nil {
1004
return err
1005
}
1006
1007
+
t.Body = string(sval)
1008
}
1009
// t.Repo (string) (string)
1010
case "repo":
···
1110
case "createdAt":
1111
1112
{
1113
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1114
if err != nil {
1115
return err
1116
}
1117
1118
+
t.CreatedAt = string(sval)
1119
}
1120
1121
default:
···
1135
}
1136
1137
cw := cbg.NewCborWriter(w)
1138
1139
+
if _, err := cw.Write([]byte{163}); err != nil {
1140
return err
1141
}
1142
···
1183
}
1184
1185
// t.State (string) (string)
1186
+
if len("state") > 1000000 {
1187
+
return xerrors.Errorf("Value in field \"state\" was too long")
1188
+
}
1189
1190
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("state"))); err != nil {
1191
+
return err
1192
+
}
1193
+
if _, err := cw.WriteString(string("state")); err != nil {
1194
+
return err
1195
+
}
1196
1197
+
if len(t.State) > 1000000 {
1198
+
return xerrors.Errorf("Value in field t.State was too long")
1199
+
}
1200
1201
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.State))); err != nil {
1202
+
return err
1203
+
}
1204
+
if _, err := cw.WriteString(string(t.State)); err != nil {
1205
+
return err
1206
}
1207
return nil
1208
}
···
1274
case "state":
1275
1276
{
1277
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1278
if err != nil {
1279
return err
1280
}
1281
1282
+
t.State = string(sval)
1283
}
1284
1285
default:
···
1305
fieldCount--
1306
}
1307
1308
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1309
return err
1310
}
···
1452
}
1453
1454
// t.CreatedAt (string) (string)
1455
+
if len("createdAt") > 1000000 {
1456
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
1457
+
}
1458
1459
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
1460
+
return err
1461
+
}
1462
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
1463
+
return err
1464
+
}
1465
1466
+
if len(t.CreatedAt) > 1000000 {
1467
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
1468
+
}
1469
1470
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
1471
+
return err
1472
+
}
1473
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
1474
+
return err
1475
}
1476
return nil
1477
}
···
1612
case "createdAt":
1613
1614
{
1615
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1616
if err != nil {
1617
return err
1618
}
1619
1620
+
t.CreatedAt = string(sval)
1621
}
1622
1623
default:
···
1637
}
1638
1639
cw := cbg.NewCborWriter(w)
1640
+
fieldCount := 7
1641
1642
+
if t.Description == nil {
1643
fieldCount--
1644
}
1645
1646
+
if t.Source == nil {
1647
fieldCount--
1648
}
1649
···
1739
return err
1740
}
1741
1742
+
// t.Source (string) (string)
1743
+
if t.Source != nil {
1744
1745
+
if len("source") > 1000000 {
1746
+
return xerrors.Errorf("Value in field \"source\" was too long")
1747
}
1748
1749
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("source"))); err != nil {
1750
return err
1751
}
1752
+
if _, err := cw.WriteString(string("source")); err != nil {
1753
return err
1754
}
1755
1756
+
if t.Source == nil {
1757
if _, err := cw.Write(cbg.CborNull); err != nil {
1758
return err
1759
}
1760
} else {
1761
+
if len(*t.Source) > 1000000 {
1762
+
return xerrors.Errorf("Value in field t.Source was too long")
1763
}
1764
1765
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Source))); err != nil {
1766
return err
1767
}
1768
+
if _, err := cw.WriteString(string(*t.Source)); err != nil {
1769
return err
1770
}
1771
}
1772
}
1773
1774
+
// t.CreatedAt (string) (string)
1775
+
if len("createdAt") > 1000000 {
1776
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
1777
+
}
1778
+
1779
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
1780
+
return err
1781
+
}
1782
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
1783
+
return err
1784
+
}
1785
+
1786
+
if len(t.CreatedAt) > 1000000 {
1787
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
1788
+
}
1789
+
1790
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
1791
+
return err
1792
+
}
1793
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
1794
+
return err
1795
+
}
1796
+
1797
// t.Description (string) (string)
1798
if t.Description != nil {
1799
···
1913
1914
t.Owner = string(sval)
1915
}
1916
+
// t.Source (string) (string)
1917
+
case "source":
1918
1919
{
1920
b, err := cr.ReadByte()
···
1931
return err
1932
}
1933
1934
+
t.Source = (*string)(&sval)
1935
}
1936
}
1937
+
// t.CreatedAt (string) (string)
1938
+
case "createdAt":
1939
+
1940
+
{
1941
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1942
+
if err != nil {
1943
+
return err
1944
+
}
1945
+
1946
+
t.CreatedAt = string(sval)
1947
+
}
1948
// t.Description (string) (string)
1949
case "description":
1950
···
1990
fieldCount--
1991
}
1992
1993
+
if t.Source == nil {
1994
fieldCount--
1995
}
1996
···
2117
}
2118
}
2119
2120
+
// t.Source (tangled.RepoPull_Source) (struct)
2121
+
if t.Source != nil {
2122
2123
+
if len("source") > 1000000 {
2124
+
return xerrors.Errorf("Value in field \"source\" was too long")
2125
}
2126
2127
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("source"))); err != nil {
2128
return err
2129
}
2130
+
if _, err := cw.WriteString(string("source")); err != nil {
2131
return err
2132
}
2133
2134
+
if err := t.Source.MarshalCBOR(cw); err != nil {
2135
+
return err
2136
}
2137
}
2138
2139
+
// t.CreatedAt (string) (string)
2140
+
if len("createdAt") > 1000000 {
2141
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
2142
+
}
2143
2144
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
2145
+
return err
2146
+
}
2147
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
2148
+
return err
2149
+
}
2150
2151
+
if len(t.CreatedAt) > 1000000 {
2152
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
2153
+
}
2154
2155
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
2156
+
return err
2157
+
}
2158
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
2159
+
return err
2160
}
2161
2162
// t.TargetRepo (string) (string)
···
2328
2329
t.PullId = int64(extraI)
2330
}
2331
+
// t.Source (tangled.RepoPull_Source) (struct)
2332
+
case "source":
2333
2334
{
2335
+
2336
b, err := cr.ReadByte()
2337
if err != nil {
2338
return err
···
2341
if err := cr.UnreadByte(); err != nil {
2342
return err
2343
}
2344
+
t.Source = new(RepoPull_Source)
2345
+
if err := t.Source.UnmarshalCBOR(cr); err != nil {
2346
+
return xerrors.Errorf("unmarshaling t.Source pointer: %w", err)
2347
}
2348
+
}
2349
2350
}
2351
+
// t.CreatedAt (string) (string)
2352
+
case "createdAt":
2353
2354
{
2355
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2356
if err != nil {
2357
return err
2358
}
2359
2360
+
t.CreatedAt = string(sval)
2361
}
2362
// t.TargetRepo (string) (string)
2363
case "targetRepo":
···
2392
2393
return nil
2394
}
2395
+
func (t *RepoPull_Source) MarshalCBOR(w io.Writer) error {
2396
if t == nil {
2397
_, err := w.Write(cbg.CborNull)
2398
return err
2399
}
2400
2401
cw := cbg.NewCborWriter(w)
2402
+
fieldCount := 2
2403
2404
+
if t.Repo == nil {
2405
fieldCount--
2406
}
2407
···
2409
return err
2410
}
2411
2412
+
// t.Repo (string) (string)
2413
+
if t.Repo != nil {
2414
+
2415
+
if len("repo") > 1000000 {
2416
+
return xerrors.Errorf("Value in field \"repo\" was too long")
2417
+
}
2418
+
2419
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
2420
+
return err
2421
+
}
2422
+
if _, err := cw.WriteString(string("repo")); err != nil {
2423
+
return err
2424
+
}
2425
+
2426
+
if t.Repo == nil {
2427
+
if _, err := cw.Write(cbg.CborNull); err != nil {
2428
+
return err
2429
+
}
2430
+
} else {
2431
+
if len(*t.Repo) > 1000000 {
2432
+
return xerrors.Errorf("Value in field t.Repo was too long")
2433
+
}
2434
+
2435
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil {
2436
+
return err
2437
+
}
2438
+
if _, err := cw.WriteString(string(*t.Repo)); err != nil {
2439
+
return err
2440
+
}
2441
+
}
2442
+
}
2443
+
2444
+
// t.Branch (string) (string)
2445
+
if len("branch") > 1000000 {
2446
+
return xerrors.Errorf("Value in field \"branch\" was too long")
2447
+
}
2448
+
2449
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("branch"))); err != nil {
2450
+
return err
2451
+
}
2452
+
if _, err := cw.WriteString(string("branch")); err != nil {
2453
+
return err
2454
+
}
2455
+
2456
+
if len(t.Branch) > 1000000 {
2457
+
return xerrors.Errorf("Value in field t.Branch was too long")
2458
+
}
2459
+
2460
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Branch))); err != nil {
2461
+
return err
2462
+
}
2463
+
if _, err := cw.WriteString(string(t.Branch)); err != nil {
2464
+
return err
2465
+
}
2466
+
return nil
2467
+
}
2468
+
2469
+
func (t *RepoPull_Source) UnmarshalCBOR(r io.Reader) (err error) {
2470
+
*t = RepoPull_Source{}
2471
+
2472
+
cr := cbg.NewCborReader(r)
2473
+
2474
+
maj, extra, err := cr.ReadHeader()
2475
+
if err != nil {
2476
+
return err
2477
+
}
2478
+
defer func() {
2479
+
if err == io.EOF {
2480
+
err = io.ErrUnexpectedEOF
2481
+
}
2482
+
}()
2483
+
2484
+
if maj != cbg.MajMap {
2485
+
return fmt.Errorf("cbor input should be of type map")
2486
+
}
2487
+
2488
+
if extra > cbg.MaxLength {
2489
+
return fmt.Errorf("RepoPull_Source: map struct too large (%d)", extra)
2490
+
}
2491
+
2492
+
n := extra
2493
+
2494
+
nameBuf := make([]byte, 6)
2495
+
for i := uint64(0); i < n; i++ {
2496
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
2497
+
if err != nil {
2498
+
return err
2499
+
}
2500
+
2501
+
if !ok {
2502
+
// Field doesn't exist on this type, so ignore it
2503
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
2504
+
return err
2505
+
}
2506
+
continue
2507
+
}
2508
+
2509
+
switch string(nameBuf[:nameLen]) {
2510
+
// t.Repo (string) (string)
2511
+
case "repo":
2512
+
2513
+
{
2514
+
b, err := cr.ReadByte()
2515
+
if err != nil {
2516
+
return err
2517
+
}
2518
+
if b != cbg.CborNull[0] {
2519
+
if err := cr.UnreadByte(); err != nil {
2520
+
return err
2521
+
}
2522
+
2523
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2524
+
if err != nil {
2525
+
return err
2526
+
}
2527
+
2528
+
t.Repo = (*string)(&sval)
2529
+
}
2530
+
}
2531
+
// t.Branch (string) (string)
2532
+
case "branch":
2533
+
2534
+
{
2535
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2536
+
if err != nil {
2537
+
return err
2538
+
}
2539
+
2540
+
t.Branch = string(sval)
2541
+
}
2542
+
2543
+
default:
2544
+
// Field doesn't exist on this type, so ignore it
2545
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
2546
+
return err
2547
+
}
2548
+
}
2549
+
}
2550
+
2551
+
return nil
2552
+
}
2553
+
func (t *RepoPullStatus) MarshalCBOR(w io.Writer) error {
2554
+
if t == nil {
2555
+
_, err := w.Write(cbg.CborNull)
2556
+
return err
2557
+
}
2558
+
2559
+
cw := cbg.NewCborWriter(w)
2560
+
2561
+
if _, err := cw.Write([]byte{163}); err != nil {
2562
+
return err
2563
+
}
2564
+
2565
// t.Pull (string) (string)
2566
if len("pull") > 1000000 {
2567
return xerrors.Errorf("Value in field \"pull\" was too long")
···
2605
}
2606
2607
// t.Status (string) (string)
2608
+
if len("status") > 1000000 {
2609
+
return xerrors.Errorf("Value in field \"status\" was too long")
2610
+
}
2611
2612
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("status"))); err != nil {
2613
+
return err
2614
+
}
2615
+
if _, err := cw.WriteString(string("status")); err != nil {
2616
+
return err
2617
+
}
2618
2619
+
if len(t.Status) > 1000000 {
2620
+
return xerrors.Errorf("Value in field t.Status was too long")
2621
+
}
2622
2623
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Status))); err != nil {
2624
+
return err
2625
+
}
2626
+
if _, err := cw.WriteString(string(t.Status)); err != nil {
2627
+
return err
2628
}
2629
return nil
2630
}
···
2696
case "status":
2697
2698
{
2699
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2700
if err != nil {
2701
return err
2702
}
2703
2704
+
t.Status = string(sval)
2705
}
2706
2707
default:
···
2723
cw := cbg.NewCborWriter(w)
2724
fieldCount := 7
2725
2726
if t.CommentId == nil {
2727
fieldCount--
2728
}
2729
···
2740
}
2741
2742
// t.Body (string) (string)
2743
+
if len("body") > 1000000 {
2744
+
return xerrors.Errorf("Value in field \"body\" was too long")
2745
+
}
2746
2747
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil {
2748
+
return err
2749
+
}
2750
+
if _, err := cw.WriteString(string("body")); err != nil {
2751
+
return err
2752
+
}
2753
2754
+
if len(t.Body) > 1000000 {
2755
+
return xerrors.Errorf("Value in field t.Body was too long")
2756
+
}
2757
2758
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil {
2759
+
return err
2760
+
}
2761
+
if _, err := cw.WriteString(string(t.Body)); err != nil {
2762
+
return err
2763
}
2764
2765
// t.Pull (string) (string)
···
2901
}
2902
2903
// t.CreatedAt (string) (string)
2904
+
if len("createdAt") > 1000000 {
2905
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
2906
+
}
2907
2908
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
2909
+
return err
2910
+
}
2911
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
2912
+
return err
2913
+
}
2914
2915
+
if len(t.CreatedAt) > 1000000 {
2916
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
2917
+
}
2918
2919
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
2920
+
return err
2921
+
}
2922
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
2923
+
return err
2924
}
2925
return nil
2926
}
···
2970
case "body":
2971
2972
{
2973
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2974
if err != nil {
2975
return err
2976
}
2977
2978
+
t.Body = string(sval)
2979
}
2980
// t.Pull (string) (string)
2981
case "pull":
···
3081
case "createdAt":
3082
3083
{
3084
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3085
+
if err != nil {
3086
+
return err
3087
+
}
3088
+
3089
+
t.CreatedAt = string(sval)
3090
+
}
3091
+
3092
+
default:
3093
+
// Field doesn't exist on this type, so ignore it
3094
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
3095
+
return err
3096
+
}
3097
+
}
3098
+
}
3099
+
3100
+
return nil
3101
+
}
3102
+
func (t *RepoArtifact) MarshalCBOR(w io.Writer) error {
3103
+
if t == nil {
3104
+
_, err := w.Write(cbg.CborNull)
3105
+
return err
3106
+
}
3107
+
3108
+
cw := cbg.NewCborWriter(w)
3109
+
fieldCount := 6
3110
+
3111
+
if t.Tag == nil {
3112
+
fieldCount--
3113
+
}
3114
+
3115
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
3116
+
return err
3117
+
}
3118
+
3119
+
// t.Tag (util.LexBytes) (slice)
3120
+
if t.Tag != nil {
3121
+
3122
+
if len("tag") > 1000000 {
3123
+
return xerrors.Errorf("Value in field \"tag\" was too long")
3124
+
}
3125
+
3126
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("tag"))); err != nil {
3127
+
return err
3128
+
}
3129
+
if _, err := cw.WriteString(string("tag")); err != nil {
3130
+
return err
3131
+
}
3132
+
3133
+
if len(t.Tag) > 2097152 {
3134
+
return xerrors.Errorf("Byte array in field t.Tag was too long")
3135
+
}
3136
+
3137
+
if err := cw.WriteMajorTypeHeader(cbg.MajByteString, uint64(len(t.Tag))); err != nil {
3138
+
return err
3139
+
}
3140
+
3141
+
if _, err := cw.Write(t.Tag); err != nil {
3142
+
return err
3143
+
}
3144
+
3145
+
}
3146
+
3147
+
// t.Name (string) (string)
3148
+
if len("name") > 1000000 {
3149
+
return xerrors.Errorf("Value in field \"name\" was too long")
3150
+
}
3151
+
3152
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil {
3153
+
return err
3154
+
}
3155
+
if _, err := cw.WriteString(string("name")); err != nil {
3156
+
return err
3157
+
}
3158
+
3159
+
if len(t.Name) > 1000000 {
3160
+
return xerrors.Errorf("Value in field t.Name was too long")
3161
+
}
3162
+
3163
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil {
3164
+
return err
3165
+
}
3166
+
if _, err := cw.WriteString(string(t.Name)); err != nil {
3167
+
return err
3168
+
}
3169
+
3170
+
// t.Repo (string) (string)
3171
+
if len("repo") > 1000000 {
3172
+
return xerrors.Errorf("Value in field \"repo\" was too long")
3173
+
}
3174
+
3175
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
3176
+
return err
3177
+
}
3178
+
if _, err := cw.WriteString(string("repo")); err != nil {
3179
+
return err
3180
+
}
3181
+
3182
+
if len(t.Repo) > 1000000 {
3183
+
return xerrors.Errorf("Value in field t.Repo was too long")
3184
+
}
3185
+
3186
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil {
3187
+
return err
3188
+
}
3189
+
if _, err := cw.WriteString(string(t.Repo)); err != nil {
3190
+
return err
3191
+
}
3192
+
3193
+
// t.LexiconTypeID (string) (string)
3194
+
if len("$type") > 1000000 {
3195
+
return xerrors.Errorf("Value in field \"$type\" was too long")
3196
+
}
3197
+
3198
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
3199
+
return err
3200
+
}
3201
+
if _, err := cw.WriteString(string("$type")); err != nil {
3202
+
return err
3203
+
}
3204
+
3205
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.artifact"))); err != nil {
3206
+
return err
3207
+
}
3208
+
if _, err := cw.WriteString(string("sh.tangled.repo.artifact")); err != nil {
3209
+
return err
3210
+
}
3211
+
3212
+
// t.Artifact (util.LexBlob) (struct)
3213
+
if len("artifact") > 1000000 {
3214
+
return xerrors.Errorf("Value in field \"artifact\" was too long")
3215
+
}
3216
+
3217
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("artifact"))); err != nil {
3218
+
return err
3219
+
}
3220
+
if _, err := cw.WriteString(string("artifact")); err != nil {
3221
+
return err
3222
+
}
3223
+
3224
+
if err := t.Artifact.MarshalCBOR(cw); err != nil {
3225
+
return err
3226
+
}
3227
+
3228
+
// t.CreatedAt (string) (string)
3229
+
if len("createdAt") > 1000000 {
3230
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
3231
+
}
3232
+
3233
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
3234
+
return err
3235
+
}
3236
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
3237
+
return err
3238
+
}
3239
+
3240
+
if len(t.CreatedAt) > 1000000 {
3241
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
3242
+
}
3243
+
3244
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
3245
+
return err
3246
+
}
3247
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
3248
+
return err
3249
+
}
3250
+
return nil
3251
+
}
3252
+
3253
+
func (t *RepoArtifact) UnmarshalCBOR(r io.Reader) (err error) {
3254
+
*t = RepoArtifact{}
3255
+
3256
+
cr := cbg.NewCborReader(r)
3257
+
3258
+
maj, extra, err := cr.ReadHeader()
3259
+
if err != nil {
3260
+
return err
3261
+
}
3262
+
defer func() {
3263
+
if err == io.EOF {
3264
+
err = io.ErrUnexpectedEOF
3265
+
}
3266
+
}()
3267
+
3268
+
if maj != cbg.MajMap {
3269
+
return fmt.Errorf("cbor input should be of type map")
3270
+
}
3271
+
3272
+
if extra > cbg.MaxLength {
3273
+
return fmt.Errorf("RepoArtifact: map struct too large (%d)", extra)
3274
+
}
3275
+
3276
+
n := extra
3277
+
3278
+
nameBuf := make([]byte, 9)
3279
+
for i := uint64(0); i < n; i++ {
3280
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
3281
+
if err != nil {
3282
+
return err
3283
+
}
3284
+
3285
+
if !ok {
3286
+
// Field doesn't exist on this type, so ignore it
3287
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
3288
+
return err
3289
+
}
3290
+
continue
3291
+
}
3292
+
3293
+
switch string(nameBuf[:nameLen]) {
3294
+
// t.Tag (util.LexBytes) (slice)
3295
+
case "tag":
3296
+
3297
+
maj, extra, err = cr.ReadHeader()
3298
+
if err != nil {
3299
+
return err
3300
+
}
3301
+
3302
+
if extra > 2097152 {
3303
+
return fmt.Errorf("t.Tag: byte array too large (%d)", extra)
3304
+
}
3305
+
if maj != cbg.MajByteString {
3306
+
return fmt.Errorf("expected byte array")
3307
+
}
3308
+
3309
+
if extra > 0 {
3310
+
t.Tag = make([]uint8, extra)
3311
+
}
3312
+
3313
+
if _, err := io.ReadFull(cr, t.Tag); err != nil {
3314
+
return err
3315
+
}
3316
+
3317
+
// t.Name (string) (string)
3318
+
case "name":
3319
+
3320
+
{
3321
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3322
+
if err != nil {
3323
+
return err
3324
+
}
3325
+
3326
+
t.Name = string(sval)
3327
+
}
3328
+
// t.Repo (string) (string)
3329
+
case "repo":
3330
+
3331
+
{
3332
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3333
+
if err != nil {
3334
+
return err
3335
+
}
3336
+
3337
+
t.Repo = string(sval)
3338
+
}
3339
+
// t.LexiconTypeID (string) (string)
3340
+
case "$type":
3341
+
3342
+
{
3343
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3344
+
if err != nil {
3345
+
return err
3346
+
}
3347
+
3348
+
t.LexiconTypeID = string(sval)
3349
+
}
3350
+
// t.Artifact (util.LexBlob) (struct)
3351
+
case "artifact":
3352
+
3353
+
{
3354
+
3355
+
b, err := cr.ReadByte()
3356
+
if err != nil {
3357
+
return err
3358
+
}
3359
+
if b != cbg.CborNull[0] {
3360
+
if err := cr.UnreadByte(); err != nil {
3361
+
return err
3362
+
}
3363
+
t.Artifact = new(util.LexBlob)
3364
+
if err := t.Artifact.UnmarshalCBOR(cr); err != nil {
3365
+
return xerrors.Errorf("unmarshaling t.Artifact pointer: %w", err)
3366
+
}
3367
+
}
3368
+
3369
+
}
3370
+
// t.CreatedAt (string) (string)
3371
+
case "createdAt":
3372
+
3373
+
{
3374
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3375
+
if err != nil {
3376
+
return err
3377
+
}
3378
+
3379
+
t.CreatedAt = string(sval)
3380
+
}
3381
+
3382
+
default:
3383
+
// Field doesn't exist on this type, so ignore it
3384
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
3385
+
return err
3386
+
}
3387
+
}
3388
+
}
3389
+
3390
+
return nil
3391
+
}
3392
+
func (t *ActorProfile) MarshalCBOR(w io.Writer) error {
3393
+
if t == nil {
3394
+
_, err := w.Write(cbg.CborNull)
3395
+
return err
3396
+
}
3397
+
3398
+
cw := cbg.NewCborWriter(w)
3399
+
fieldCount := 7
3400
+
3401
+
if t.Description == nil {
3402
+
fieldCount--
3403
+
}
3404
+
3405
+
if t.Links == nil {
3406
+
fieldCount--
3407
+
}
3408
+
3409
+
if t.Location == nil {
3410
+
fieldCount--
3411
+
}
3412
+
3413
+
if t.PinnedRepositories == nil {
3414
+
fieldCount--
3415
+
}
3416
+
3417
+
if t.Stats == nil {
3418
+
fieldCount--
3419
+
}
3420
+
3421
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
3422
+
return err
3423
+
}
3424
+
3425
+
// t.LexiconTypeID (string) (string)
3426
+
if len("$type") > 1000000 {
3427
+
return xerrors.Errorf("Value in field \"$type\" was too long")
3428
+
}
3429
+
3430
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
3431
+
return err
3432
+
}
3433
+
if _, err := cw.WriteString(string("$type")); err != nil {
3434
+
return err
3435
+
}
3436
+
3437
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.actor.profile"))); err != nil {
3438
+
return err
3439
+
}
3440
+
if _, err := cw.WriteString(string("sh.tangled.actor.profile")); err != nil {
3441
+
return err
3442
+
}
3443
+
3444
+
// t.Links ([]string) (slice)
3445
+
if t.Links != nil {
3446
+
3447
+
if len("links") > 1000000 {
3448
+
return xerrors.Errorf("Value in field \"links\" was too long")
3449
+
}
3450
+
3451
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("links"))); err != nil {
3452
+
return err
3453
+
}
3454
+
if _, err := cw.WriteString(string("links")); err != nil {
3455
+
return err
3456
+
}
3457
+
3458
+
if len(t.Links) > 8192 {
3459
+
return xerrors.Errorf("Slice value in field t.Links was too long")
3460
+
}
3461
+
3462
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Links))); err != nil {
3463
+
return err
3464
+
}
3465
+
for _, v := range t.Links {
3466
+
if len(v) > 1000000 {
3467
+
return xerrors.Errorf("Value in field v was too long")
3468
+
}
3469
+
3470
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
3471
+
return err
3472
+
}
3473
+
if _, err := cw.WriteString(string(v)); err != nil {
3474
+
return err
3475
+
}
3476
+
3477
+
}
3478
+
}
3479
+
3480
+
// t.Stats ([]string) (slice)
3481
+
if t.Stats != nil {
3482
+
3483
+
if len("stats") > 1000000 {
3484
+
return xerrors.Errorf("Value in field \"stats\" was too long")
3485
+
}
3486
+
3487
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("stats"))); err != nil {
3488
+
return err
3489
+
}
3490
+
if _, err := cw.WriteString(string("stats")); err != nil {
3491
+
return err
3492
+
}
3493
+
3494
+
if len(t.Stats) > 8192 {
3495
+
return xerrors.Errorf("Slice value in field t.Stats was too long")
3496
+
}
3497
+
3498
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Stats))); err != nil {
3499
+
return err
3500
+
}
3501
+
for _, v := range t.Stats {
3502
+
if len(v) > 1000000 {
3503
+
return xerrors.Errorf("Value in field v was too long")
3504
+
}
3505
+
3506
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
3507
+
return err
3508
+
}
3509
+
if _, err := cw.WriteString(string(v)); err != nil {
3510
+
return err
3511
+
}
3512
+
3513
+
}
3514
+
}
3515
+
3516
+
// t.Bluesky (bool) (bool)
3517
+
if len("bluesky") > 1000000 {
3518
+
return xerrors.Errorf("Value in field \"bluesky\" was too long")
3519
+
}
3520
+
3521
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("bluesky"))); err != nil {
3522
+
return err
3523
+
}
3524
+
if _, err := cw.WriteString(string("bluesky")); err != nil {
3525
+
return err
3526
+
}
3527
+
3528
+
if err := cbg.WriteBool(w, t.Bluesky); err != nil {
3529
+
return err
3530
+
}
3531
+
3532
+
// t.Location (string) (string)
3533
+
if t.Location != nil {
3534
+
3535
+
if len("location") > 1000000 {
3536
+
return xerrors.Errorf("Value in field \"location\" was too long")
3537
+
}
3538
+
3539
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("location"))); err != nil {
3540
+
return err
3541
+
}
3542
+
if _, err := cw.WriteString(string("location")); err != nil {
3543
+
return err
3544
+
}
3545
+
3546
+
if t.Location == nil {
3547
+
if _, err := cw.Write(cbg.CborNull); err != nil {
3548
+
return err
3549
+
}
3550
+
} else {
3551
+
if len(*t.Location) > 1000000 {
3552
+
return xerrors.Errorf("Value in field t.Location was too long")
3553
+
}
3554
+
3555
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Location))); err != nil {
3556
+
return err
3557
+
}
3558
+
if _, err := cw.WriteString(string(*t.Location)); err != nil {
3559
+
return err
3560
+
}
3561
+
}
3562
+
}
3563
+
3564
+
// t.Description (string) (string)
3565
+
if t.Description != nil {
3566
+
3567
+
if len("description") > 1000000 {
3568
+
return xerrors.Errorf("Value in field \"description\" was too long")
3569
+
}
3570
+
3571
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil {
3572
+
return err
3573
+
}
3574
+
if _, err := cw.WriteString(string("description")); err != nil {
3575
+
return err
3576
+
}
3577
+
3578
+
if t.Description == nil {
3579
+
if _, err := cw.Write(cbg.CborNull); err != nil {
3580
+
return err
3581
+
}
3582
+
} else {
3583
+
if len(*t.Description) > 1000000 {
3584
+
return xerrors.Errorf("Value in field t.Description was too long")
3585
+
}
3586
+
3587
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Description))); err != nil {
3588
+
return err
3589
+
}
3590
+
if _, err := cw.WriteString(string(*t.Description)); err != nil {
3591
+
return err
3592
+
}
3593
+
}
3594
+
}
3595
+
3596
+
// t.PinnedRepositories ([]string) (slice)
3597
+
if t.PinnedRepositories != nil {
3598
+
3599
+
if len("pinnedRepositories") > 1000000 {
3600
+
return xerrors.Errorf("Value in field \"pinnedRepositories\" was too long")
3601
+
}
3602
+
3603
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pinnedRepositories"))); err != nil {
3604
+
return err
3605
+
}
3606
+
if _, err := cw.WriteString(string("pinnedRepositories")); err != nil {
3607
+
return err
3608
+
}
3609
+
3610
+
if len(t.PinnedRepositories) > 8192 {
3611
+
return xerrors.Errorf("Slice value in field t.PinnedRepositories was too long")
3612
+
}
3613
+
3614
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.PinnedRepositories))); err != nil {
3615
+
return err
3616
+
}
3617
+
for _, v := range t.PinnedRepositories {
3618
+
if len(v) > 1000000 {
3619
+
return xerrors.Errorf("Value in field v was too long")
3620
+
}
3621
+
3622
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
3623
+
return err
3624
+
}
3625
+
if _, err := cw.WriteString(string(v)); err != nil {
3626
+
return err
3627
+
}
3628
+
3629
+
}
3630
+
}
3631
+
return nil
3632
+
}
3633
+
3634
+
func (t *ActorProfile) UnmarshalCBOR(r io.Reader) (err error) {
3635
+
*t = ActorProfile{}
3636
+
3637
+
cr := cbg.NewCborReader(r)
3638
+
3639
+
maj, extra, err := cr.ReadHeader()
3640
+
if err != nil {
3641
+
return err
3642
+
}
3643
+
defer func() {
3644
+
if err == io.EOF {
3645
+
err = io.ErrUnexpectedEOF
3646
+
}
3647
+
}()
3648
+
3649
+
if maj != cbg.MajMap {
3650
+
return fmt.Errorf("cbor input should be of type map")
3651
+
}
3652
+
3653
+
if extra > cbg.MaxLength {
3654
+
return fmt.Errorf("ActorProfile: map struct too large (%d)", extra)
3655
+
}
3656
+
3657
+
n := extra
3658
+
3659
+
nameBuf := make([]byte, 18)
3660
+
for i := uint64(0); i < n; i++ {
3661
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
3662
+
if err != nil {
3663
+
return err
3664
+
}
3665
+
3666
+
if !ok {
3667
+
// Field doesn't exist on this type, so ignore it
3668
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
3669
+
return err
3670
+
}
3671
+
continue
3672
+
}
3673
+
3674
+
switch string(nameBuf[:nameLen]) {
3675
+
// t.LexiconTypeID (string) (string)
3676
+
case "$type":
3677
+
3678
+
{
3679
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3680
+
if err != nil {
3681
+
return err
3682
+
}
3683
+
3684
+
t.LexiconTypeID = string(sval)
3685
+
}
3686
+
// t.Links ([]string) (slice)
3687
+
case "links":
3688
+
3689
+
maj, extra, err = cr.ReadHeader()
3690
+
if err != nil {
3691
+
return err
3692
+
}
3693
+
3694
+
if extra > 8192 {
3695
+
return fmt.Errorf("t.Links: array too large (%d)", extra)
3696
+
}
3697
+
3698
+
if maj != cbg.MajArray {
3699
+
return fmt.Errorf("expected cbor array")
3700
+
}
3701
+
3702
+
if extra > 0 {
3703
+
t.Links = make([]string, extra)
3704
+
}
3705
+
3706
+
for i := 0; i < int(extra); i++ {
3707
+
{
3708
+
var maj byte
3709
+
var extra uint64
3710
+
var err error
3711
+
_ = maj
3712
+
_ = extra
3713
+
_ = err
3714
+
3715
+
{
3716
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3717
+
if err != nil {
3718
+
return err
3719
+
}
3720
+
3721
+
t.Links[i] = string(sval)
3722
+
}
3723
+
3724
+
}
3725
+
}
3726
+
// t.Stats ([]string) (slice)
3727
+
case "stats":
3728
+
3729
+
maj, extra, err = cr.ReadHeader()
3730
+
if err != nil {
3731
+
return err
3732
+
}
3733
+
3734
+
if extra > 8192 {
3735
+
return fmt.Errorf("t.Stats: array too large (%d)", extra)
3736
+
}
3737
+
3738
+
if maj != cbg.MajArray {
3739
+
return fmt.Errorf("expected cbor array")
3740
+
}
3741
+
3742
+
if extra > 0 {
3743
+
t.Stats = make([]string, extra)
3744
+
}
3745
+
3746
+
for i := 0; i < int(extra); i++ {
3747
+
{
3748
+
var maj byte
3749
+
var extra uint64
3750
+
var err error
3751
+
_ = maj
3752
+
_ = extra
3753
+
_ = err
3754
+
3755
+
{
3756
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3757
+
if err != nil {
3758
+
return err
3759
+
}
3760
+
3761
+
t.Stats[i] = string(sval)
3762
+
}
3763
+
3764
+
}
3765
+
}
3766
+
// t.Bluesky (bool) (bool)
3767
+
case "bluesky":
3768
+
3769
+
maj, extra, err = cr.ReadHeader()
3770
+
if err != nil {
3771
+
return err
3772
+
}
3773
+
if maj != cbg.MajOther {
3774
+
return fmt.Errorf("booleans must be major type 7")
3775
+
}
3776
+
switch extra {
3777
+
case 20:
3778
+
t.Bluesky = false
3779
+
case 21:
3780
+
t.Bluesky = true
3781
+
default:
3782
+
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
3783
+
}
3784
+
// t.Location (string) (string)
3785
+
case "location":
3786
+
3787
+
{
3788
b, err := cr.ReadByte()
3789
if err != nil {
3790
return err
···
3799
return err
3800
}
3801
3802
+
t.Location = (*string)(&sval)
3803
+
}
3804
+
}
3805
+
// t.Description (string) (string)
3806
+
case "description":
3807
+
3808
+
{
3809
+
b, err := cr.ReadByte()
3810
+
if err != nil {
3811
+
return err
3812
+
}
3813
+
if b != cbg.CborNull[0] {
3814
+
if err := cr.UnreadByte(); err != nil {
3815
+
return err
3816
+
}
3817
+
3818
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3819
+
if err != nil {
3820
+
return err
3821
+
}
3822
+
3823
+
t.Description = (*string)(&sval)
3824
+
}
3825
+
}
3826
+
// t.PinnedRepositories ([]string) (slice)
3827
+
case "pinnedRepositories":
3828
+
3829
+
maj, extra, err = cr.ReadHeader()
3830
+
if err != nil {
3831
+
return err
3832
+
}
3833
+
3834
+
if extra > 8192 {
3835
+
return fmt.Errorf("t.PinnedRepositories: array too large (%d)", extra)
3836
+
}
3837
+
3838
+
if maj != cbg.MajArray {
3839
+
return fmt.Errorf("expected cbor array")
3840
+
}
3841
+
3842
+
if extra > 0 {
3843
+
t.PinnedRepositories = make([]string, extra)
3844
+
}
3845
+
3846
+
for i := 0; i < int(extra); i++ {
3847
+
{
3848
+
var maj byte
3849
+
var extra uint64
3850
+
var err error
3851
+
_ = maj
3852
+
_ = extra
3853
+
_ = err
3854
+
3855
+
{
3856
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3857
+
if err != nil {
3858
+
return err
3859
+
}
3860
+
3861
+
t.PinnedRepositories[i] = string(sval)
3862
+
}
3863
+
3864
}
3865
}
3866
+2
-2
api/tangled/issuecomment.go
+2
-2
api/tangled/issuecomment.go
···
18
// RECORDTYPE: RepoIssueComment
19
type RepoIssueComment struct {
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"`
21
-
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
23
-
CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"`
24
Issue string `json:"issue" cborgen:"issue"`
25
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
26
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
···
18
// RECORDTYPE: RepoIssueComment
19
type RepoIssueComment struct {
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"`
21
+
Body string `json:"body" cborgen:"body"`
22
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
23
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
24
Issue string `json:"issue" cborgen:"issue"`
25
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
26
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
+1
-1
api/tangled/issuestate.go
+1
-1
api/tangled/issuestate.go
+4
-4
api/tangled/knotmember.go
+4
-4
api/tangled/knotmember.go
···
17
} //
18
// RECORDTYPE: KnotMember
19
type KnotMember struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.knot.member" cborgen:"$type,const=sh.tangled.knot.member"`
21
-
AddedAt *string `json:"addedAt,omitempty" cborgen:"addedAt,omitempty"`
22
// domain: domain that this member now belongs to
23
-
Domain string `json:"domain" cborgen:"domain"`
24
-
Member string `json:"member" cborgen:"member"`
25
}
···
17
} //
18
// RECORDTYPE: KnotMember
19
type KnotMember struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.knot.member" cborgen:"$type,const=sh.tangled.knot.member"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
// domain: domain that this member now belongs to
23
+
Domain string `json:"domain" cborgen:"domain"`
24
+
Subject string `json:"subject" cborgen:"subject"`
25
}
+2
-2
api/tangled/pullcomment.go
+2
-2
api/tangled/pullcomment.go
···
18
// RECORDTYPE: RepoPullComment
19
type RepoPullComment struct {
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
21
-
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
23
-
CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"`
24
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
25
Pull string `json:"pull" cborgen:"pull"`
26
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
···
18
// RECORDTYPE: RepoPullComment
19
type RepoPullComment struct {
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
21
+
Body string `json:"body" cborgen:"body"`
22
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
23
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
24
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
25
Pull string `json:"pull" cborgen:"pull"`
26
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
+1
-1
api/tangled/pullstatus.go
+1
-1
api/tangled/pullstatus.go
+31
api/tangled/repoartifact.go
+31
api/tangled/repoartifact.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.artifact
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
RepoArtifactNSID = "sh.tangled.repo.artifact"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.repo.artifact", &RepoArtifact{})
17
+
} //
18
+
// RECORDTYPE: RepoArtifact
19
+
type RepoArtifact struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.artifact" cborgen:"$type,const=sh.tangled.repo.artifact"`
21
+
// artifact: the artifact
22
+
Artifact *util.LexBlob `json:"artifact" cborgen:"artifact"`
23
+
// createdAt: time of creation of this artifact
24
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
25
+
// name: name of the artifact
26
+
Name string `json:"name" cborgen:"name"`
27
+
// repo: repo that this artifact is being uploaded to
28
+
Repo string `json:"repo" cborgen:"repo"`
29
+
// tag: hash of the tag object that this artifact is attached to (only annotated tags are supported)
30
+
Tag util.LexBytes `json:"tag,omitempty" cborgen:"tag,omitempty"`
31
+
}
+1
-1
api/tangled/repoissue.go
+1
-1
api/tangled/repoissue.go
···
19
type RepoIssue struct {
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
21
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
-
CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"`
23
IssueId int64 `json:"issueId" cborgen:"issueId"`
24
Owner string `json:"owner" cborgen:"owner"`
25
Repo string `json:"repo" cborgen:"repo"`
···
19
type RepoIssue struct {
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
21
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
IssueId int64 `json:"issueId" cborgen:"issueId"`
24
Owner string `json:"owner" cborgen:"owner"`
25
Repo string `json:"repo" cborgen:"repo"`
+15
-9
api/tangled/repopull.go
+15
-9
api/tangled/repopull.go
···
17
} //
18
// RECORDTYPE: RepoPull
19
type RepoPull struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
-
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
-
CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"`
23
-
Patch string `json:"patch" cborgen:"patch"`
24
-
PullId int64 `json:"pullId" cborgen:"pullId"`
25
-
SourceRepo *string `json:"sourceRepo,omitempty" cborgen:"sourceRepo,omitempty"`
26
-
TargetBranch string `json:"targetBranch" cborgen:"targetBranch"`
27
-
TargetRepo string `json:"targetRepo" cborgen:"targetRepo"`
28
-
Title string `json:"title" cborgen:"title"`
29
}
···
17
} //
18
// RECORDTYPE: RepoPull
19
type RepoPull struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
+
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Patch string `json:"patch" cborgen:"patch"`
24
+
PullId int64 `json:"pullId" cborgen:"pullId"`
25
+
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
26
+
TargetBranch string `json:"targetBranch" cborgen:"targetBranch"`
27
+
TargetRepo string `json:"targetRepo" cborgen:"targetRepo"`
28
+
Title string `json:"title" cborgen:"title"`
29
+
}
30
+
31
+
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
32
+
type RepoPull_Source struct {
33
+
Branch string `json:"branch" cborgen:"branch"`
34
+
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
35
}
+2
-2
api/tangled/tangledpublicKey.go
+2
-2
api/tangled/tangledpublicKey.go
···
18
// RECORDTYPE: PublicKey
19
type PublicKey struct {
20
LexiconTypeID string `json:"$type,const=sh.tangled.publicKey" cborgen:"$type,const=sh.tangled.publicKey"`
21
-
// created: key upload timestamp
22
-
Created string `json:"created" cborgen:"created"`
23
// key: public key contents
24
Key string `json:"key" cborgen:"key"`
25
// name: human-readable name for this key
···
18
// RECORDTYPE: PublicKey
19
type PublicKey struct {
20
LexiconTypeID string `json:"$type,const=sh.tangled.publicKey" cborgen:"$type,const=sh.tangled.publicKey"`
21
+
// createdAt: key upload timestamp
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
// key: public key contents
24
Key string `json:"key" cborgen:"key"`
25
// name: human-readable name for this key
+3
-1
api/tangled/tangledrepo.go
+3
-1
api/tangled/tangledrepo.go
···
18
// RECORDTYPE: Repo
19
type Repo struct {
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo" cborgen:"$type,const=sh.tangled.repo"`
21
-
AddedAt *string `json:"addedAt,omitempty" cborgen:"addedAt,omitempty"`
22
Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
23
// knot: knot where the repo was created
24
Knot string `json:"knot" cborgen:"knot"`
25
// name: name of the repo
26
Name string `json:"name" cborgen:"name"`
27
Owner string `json:"owner" cborgen:"owner"`
28
}
···
18
// RECORDTYPE: Repo
19
type Repo struct {
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo" cborgen:"$type,const=sh.tangled.repo"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
23
// knot: knot where the repo was created
24
Knot string `json:"knot" cborgen:"knot"`
25
// name: name of the repo
26
Name string `json:"name" cborgen:"name"`
27
Owner string `json:"owner" cborgen:"owner"`
28
+
// source: source of the repo
29
+
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
30
}
-211
appview/auth/auth.go
-211
appview/auth/auth.go
···
1
-
package auth
2
-
3
-
import (
4
-
"context"
5
-
"fmt"
6
-
"net/http"
7
-
"time"
8
-
9
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
10
-
"github.com/bluesky-social/indigo/atproto/identity"
11
-
"github.com/bluesky-social/indigo/xrpc"
12
-
"github.com/gorilla/sessions"
13
-
"tangled.sh/tangled.sh/core/appview"
14
-
)
15
-
16
-
type Auth struct {
17
-
Store *sessions.CookieStore
18
-
}
19
-
20
-
type AtSessionCreate struct {
21
-
comatproto.ServerCreateSession_Output
22
-
PDSEndpoint string
23
-
}
24
-
25
-
type AtSessionRefresh struct {
26
-
comatproto.ServerRefreshSession_Output
27
-
PDSEndpoint string
28
-
}
29
-
30
-
func Make(secret string) (*Auth, error) {
31
-
store := sessions.NewCookieStore([]byte(secret))
32
-
return &Auth{store}, nil
33
-
}
34
-
35
-
func (a *Auth) CreateInitialSession(ctx context.Context, resolved *identity.Identity, appPassword string) (*comatproto.ServerCreateSession_Output, error) {
36
-
37
-
pdsUrl := resolved.PDSEndpoint()
38
-
client := xrpc.Client{
39
-
Host: pdsUrl,
40
-
}
41
-
42
-
atSession, err := comatproto.ServerCreateSession(ctx, &client, &comatproto.ServerCreateSession_Input{
43
-
Identifier: resolved.DID.String(),
44
-
Password: appPassword,
45
-
})
46
-
if err != nil {
47
-
return nil, fmt.Errorf("invalid app password")
48
-
}
49
-
50
-
return atSession, nil
51
-
}
52
-
53
-
// Sessionish is an interface that provides access to the common fields of both types.
54
-
type Sessionish interface {
55
-
GetAccessJwt() string
56
-
GetActive() *bool
57
-
GetDid() string
58
-
GetDidDoc() *interface{}
59
-
GetHandle() string
60
-
GetRefreshJwt() string
61
-
GetStatus() *string
62
-
}
63
-
64
-
// Create a wrapper type for ServerRefreshSession_Output
65
-
type RefreshSessionWrapper struct {
66
-
*comatproto.ServerRefreshSession_Output
67
-
}
68
-
69
-
func (s *RefreshSessionWrapper) GetAccessJwt() string {
70
-
return s.AccessJwt
71
-
}
72
-
73
-
func (s *RefreshSessionWrapper) GetActive() *bool {
74
-
return s.Active
75
-
}
76
-
77
-
func (s *RefreshSessionWrapper) GetDid() string {
78
-
return s.Did
79
-
}
80
-
81
-
func (s *RefreshSessionWrapper) GetDidDoc() *interface{} {
82
-
return s.DidDoc
83
-
}
84
-
85
-
func (s *RefreshSessionWrapper) GetHandle() string {
86
-
return s.Handle
87
-
}
88
-
89
-
func (s *RefreshSessionWrapper) GetRefreshJwt() string {
90
-
return s.RefreshJwt
91
-
}
92
-
93
-
func (s *RefreshSessionWrapper) GetStatus() *string {
94
-
return s.Status
95
-
}
96
-
97
-
// Create a wrapper type for ServerRefreshSession_Output
98
-
type CreateSessionWrapper struct {
99
-
*comatproto.ServerCreateSession_Output
100
-
}
101
-
102
-
func (s *CreateSessionWrapper) GetAccessJwt() string {
103
-
return s.AccessJwt
104
-
}
105
-
106
-
func (s *CreateSessionWrapper) GetActive() *bool {
107
-
return s.Active
108
-
}
109
-
110
-
func (s *CreateSessionWrapper) GetDid() string {
111
-
return s.Did
112
-
}
113
-
114
-
func (s *CreateSessionWrapper) GetDidDoc() *interface{} {
115
-
return s.DidDoc
116
-
}
117
-
118
-
func (s *CreateSessionWrapper) GetHandle() string {
119
-
return s.Handle
120
-
}
121
-
122
-
func (s *CreateSessionWrapper) GetRefreshJwt() string {
123
-
return s.RefreshJwt
124
-
}
125
-
126
-
func (s *CreateSessionWrapper) GetStatus() *string {
127
-
return s.Status
128
-
}
129
-
130
-
func (a *Auth) ClearSession(r *http.Request, w http.ResponseWriter) error {
131
-
clientSession, _ := a.Store.Get(r, appview.SessionName)
132
-
clientSession.Options.MaxAge = -1
133
-
return clientSession.Save(r, w)
134
-
}
135
-
136
-
func (a *Auth) StoreSession(r *http.Request, w http.ResponseWriter, atSessionish Sessionish, pdsEndpoint string) error {
137
-
clientSession, _ := a.Store.Get(r, appview.SessionName)
138
-
clientSession.Values[appview.SessionHandle] = atSessionish.GetHandle()
139
-
clientSession.Values[appview.SessionDid] = atSessionish.GetDid()
140
-
clientSession.Values[appview.SessionPds] = pdsEndpoint
141
-
clientSession.Values[appview.SessionAccessJwt] = atSessionish.GetAccessJwt()
142
-
clientSession.Values[appview.SessionRefreshJwt] = atSessionish.GetRefreshJwt()
143
-
clientSession.Values[appview.SessionExpiry] = time.Now().Add(time.Minute * 15).Format(time.RFC3339)
144
-
clientSession.Values[appview.SessionAuthenticated] = true
145
-
return clientSession.Save(r, w)
146
-
}
147
-
148
-
func (a *Auth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) {
149
-
clientSession, err := a.Store.Get(r, "appview-session")
150
-
if err != nil || clientSession.IsNew {
151
-
return nil, err
152
-
}
153
-
154
-
did := clientSession.Values["did"].(string)
155
-
pdsUrl := clientSession.Values["pds"].(string)
156
-
accessJwt := clientSession.Values["accessJwt"].(string)
157
-
refreshJwt := clientSession.Values["refreshJwt"].(string)
158
-
159
-
client := &xrpc.Client{
160
-
Host: pdsUrl,
161
-
Auth: &xrpc.AuthInfo{
162
-
AccessJwt: accessJwt,
163
-
RefreshJwt: refreshJwt,
164
-
Did: did,
165
-
},
166
-
}
167
-
168
-
return client, nil
169
-
}
170
-
171
-
func (a *Auth) GetSession(r *http.Request) (*sessions.Session, error) {
172
-
return a.Store.Get(r, appview.SessionName)
173
-
}
174
-
175
-
func (a *Auth) GetDid(r *http.Request) string {
176
-
clientSession, err := a.Store.Get(r, appview.SessionName)
177
-
if err != nil || clientSession.IsNew {
178
-
return ""
179
-
}
180
-
181
-
return clientSession.Values[appview.SessionDid].(string)
182
-
}
183
-
184
-
func (a *Auth) GetHandle(r *http.Request) string {
185
-
clientSession, err := a.Store.Get(r, appview.SessionName)
186
-
if err != nil || clientSession.IsNew {
187
-
return ""
188
-
}
189
-
190
-
return clientSession.Values[appview.SessionHandle].(string)
191
-
}
192
-
193
-
type User struct {
194
-
Handle string
195
-
Did string
196
-
Pds string
197
-
}
198
-
199
-
func (a *Auth) GetUser(r *http.Request) *User {
200
-
clientSession, err := a.Store.Get(r, appview.SessionName)
201
-
202
-
if err != nil || clientSession.IsNew {
203
-
return nil
204
-
}
205
-
206
-
return &User{
207
-
Handle: clientSession.Values[appview.SessionHandle].(string),
208
-
Did: clientSession.Values[appview.SessionDid].(string),
209
-
Pds: clientSession.Values[appview.SessionPds].(string),
210
-
}
211
-
}
···
+36
-6
appview/config.go
+36
-6
appview/config.go
···
6
"github.com/sethvargo/go-envconfig"
7
)
8
9
type Config struct {
10
-
CookieSecret string `env:"TANGLED_COOKIE_SECRET, default=00000000000000000000000000000000"`
11
-
DbPath string `env:"TANGLED_DB_PATH, default=appview.db"`
12
-
ListenAddr string `env:"TANGLED_LISTEN_ADDR, default=0.0.0.0:3000"`
13
-
Dev bool `env:"TANGLED_DEV, default=false"`
14
-
JetstreamEndpoint string `env:"TANGLED_JETSTREAM_ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"`
15
-
ResendApiKey string `env:"TANGLED_RESEND_API_KEY"`
16
}
17
18
func LoadConfig(ctx context.Context) (*Config, error) {
···
6
"github.com/sethvargo/go-envconfig"
7
)
8
9
+
type CoreConfig struct {
10
+
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
11
+
DbPath string `env:"DB_PATH, default=appview.db"`
12
+
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
13
+
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
14
+
Dev bool `env:"DEV, default=false"`
15
+
}
16
+
17
+
type OAuthConfig struct {
18
+
Jwks string `env:"JWKS"`
19
+
}
20
+
21
+
type JetstreamConfig struct {
22
+
Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"`
23
+
}
24
+
25
+
type ResendConfig struct {
26
+
ApiKey string `env:"API_KEY"`
27
+
}
28
+
29
+
type CamoConfig struct {
30
+
Host string `env:"HOST, default=https://camo.tangled.sh"`
31
+
SharedSecret string `env:"SHARED_SECRET"`
32
+
}
33
+
34
+
type AvatarConfig struct {
35
+
Host string `env:"HOST, default=https://avatar.tangled.sh"`
36
+
SharedSecret string `env:"SHARED_SECRET"`
37
+
}
38
+
39
type Config struct {
40
+
Core CoreConfig `env:",prefix=TANGLED_"`
41
+
Jetstream JetstreamConfig `env:",prefix=TANGLED_JETSTREAM_"`
42
+
Resend ResendConfig `env:",prefix=TANGLED_RESEND_"`
43
+
Camo CamoConfig `env:",prefix=TANGLED_CAMO_"`
44
+
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
45
+
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
46
}
47
48
func LoadConfig(ctx context.Context) (*Config, error) {
+3
appview/consts.go
+3
appview/consts.go
+150
appview/db/artifact.go
+150
appview/db/artifact.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
"time"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"github.com/go-git/go-git/v5/plumbing"
10
+
"github.com/ipfs/go-cid"
11
+
"tangled.sh/tangled.sh/core/api/tangled"
12
+
)
13
+
14
+
type Artifact struct {
15
+
Id uint64
16
+
Did string
17
+
Rkey string
18
+
19
+
RepoAt syntax.ATURI
20
+
Tag plumbing.Hash
21
+
CreatedAt time.Time
22
+
23
+
BlobCid cid.Cid
24
+
Name string
25
+
Size uint64
26
+
MimeType string
27
+
}
28
+
29
+
func (a *Artifact) ArtifactAt() syntax.ATURI {
30
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoPullNSID, a.Rkey))
31
+
}
32
+
33
+
func AddArtifact(e Execer, artifact Artifact) error {
34
+
_, err := e.Exec(
35
+
`insert or ignore into artifacts (
36
+
did,
37
+
rkey,
38
+
repo_at,
39
+
tag,
40
+
created,
41
+
blob_cid,
42
+
name,
43
+
size,
44
+
mimetype
45
+
)
46
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
47
+
artifact.Did,
48
+
artifact.Rkey,
49
+
artifact.RepoAt,
50
+
artifact.Tag[:],
51
+
artifact.CreatedAt.Format(time.RFC3339),
52
+
artifact.BlobCid.String(),
53
+
artifact.Name,
54
+
artifact.Size,
55
+
artifact.MimeType,
56
+
)
57
+
return err
58
+
}
59
+
60
+
func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) {
61
+
var artifacts []Artifact
62
+
63
+
var conditions []string
64
+
var args []any
65
+
for _, filter := range filters {
66
+
conditions = append(conditions, filter.Condition())
67
+
args = append(args, filter.arg)
68
+
}
69
+
70
+
whereClause := ""
71
+
if conditions != nil {
72
+
whereClause = " where " + strings.Join(conditions, " and ")
73
+
}
74
+
75
+
query := fmt.Sprintf(`select
76
+
did,
77
+
rkey,
78
+
repo_at,
79
+
tag,
80
+
created,
81
+
blob_cid,
82
+
name,
83
+
size,
84
+
mimetype
85
+
from artifacts %s`,
86
+
whereClause,
87
+
)
88
+
89
+
rows, err := e.Query(query, args...)
90
+
91
+
if err != nil {
92
+
return nil, err
93
+
}
94
+
defer rows.Close()
95
+
96
+
for rows.Next() {
97
+
var artifact Artifact
98
+
var createdAt string
99
+
var tag []byte
100
+
var blobCid string
101
+
102
+
if err := rows.Scan(
103
+
&artifact.Did,
104
+
&artifact.Rkey,
105
+
&artifact.RepoAt,
106
+
&tag,
107
+
&createdAt,
108
+
&blobCid,
109
+
&artifact.Name,
110
+
&artifact.Size,
111
+
&artifact.MimeType,
112
+
); err != nil {
113
+
return nil, err
114
+
}
115
+
116
+
artifact.CreatedAt, err = time.Parse(time.RFC3339, createdAt)
117
+
if err != nil {
118
+
artifact.CreatedAt = time.Now()
119
+
}
120
+
artifact.Tag = plumbing.Hash(tag)
121
+
artifact.BlobCid = cid.MustParse(blobCid)
122
+
123
+
artifacts = append(artifacts, artifact)
124
+
}
125
+
126
+
if err := rows.Err(); err != nil {
127
+
return nil, err
128
+
}
129
+
130
+
return artifacts, nil
131
+
}
132
+
133
+
func DeleteArtifact(e Execer, filters ...filter) error {
134
+
var conditions []string
135
+
var args []any
136
+
for _, filter := range filters {
137
+
conditions = append(conditions, filter.Condition())
138
+
args = append(args, filter.arg)
139
+
}
140
+
141
+
whereClause := ""
142
+
if conditions != nil {
143
+
whereClause = " where " + strings.Join(conditions, " and ")
144
+
}
145
+
146
+
query := fmt.Sprintf(`delete from artifacts %s`, whereClause)
147
+
148
+
_, err := e.Exec(query, args...)
149
+
return err
150
+
}
+154
appview/db/db.go
+154
appview/db/db.go
···
3
import (
4
"context"
5
"database/sql"
6
"log"
7
8
_ "github.com/mattn/go-sqlite3"
···
208
unique(did, email)
209
);
210
211
create table if not exists migrations (
212
id integer primary key autoincrement,
213
name text unique
···
248
return nil
249
})
250
251
return &DB{db}, nil
252
}
253
···
293
294
return nil
295
}
···
3
import (
4
"context"
5
"database/sql"
6
+
"fmt"
7
"log"
8
9
_ "github.com/mattn/go-sqlite3"
···
209
unique(did, email)
210
);
211
212
+
create table if not exists artifacts (
213
+
-- id
214
+
id integer primary key autoincrement,
215
+
did text not null,
216
+
rkey text not null,
217
+
218
+
-- meta
219
+
repo_at text not null,
220
+
tag binary(20) not null,
221
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
222
+
223
+
-- data
224
+
blob_cid text not null,
225
+
name text not null,
226
+
size integer not null default 0,
227
+
mimetype string not null default "*/*",
228
+
229
+
-- constraints
230
+
unique(did, rkey), -- record must be unique
231
+
unique(repo_at, tag, name), -- for a given tag object, each file must be unique
232
+
foreign key (repo_at) references repos(at_uri) on delete cascade
233
+
);
234
+
235
+
create table if not exists profile (
236
+
-- id
237
+
id integer primary key autoincrement,
238
+
did text not null,
239
+
240
+
-- data
241
+
description text not null,
242
+
include_bluesky integer not null default 0,
243
+
location text,
244
+
245
+
-- constraints
246
+
unique(did)
247
+
);
248
+
create table if not exists profile_links (
249
+
-- id
250
+
id integer primary key autoincrement,
251
+
did text not null,
252
+
253
+
-- data
254
+
link text not null,
255
+
256
+
-- constraints
257
+
foreign key (did) references profile(did) on delete cascade
258
+
);
259
+
create table if not exists profile_stats (
260
+
-- id
261
+
id integer primary key autoincrement,
262
+
did text not null,
263
+
264
+
-- data
265
+
kind text not null check (kind in (
266
+
"merged-pull-request-count",
267
+
"closed-pull-request-count",
268
+
"open-pull-request-count",
269
+
"open-issue-count",
270
+
"closed-issue-count",
271
+
"repository-count"
272
+
)),
273
+
274
+
-- constraints
275
+
foreign key (did) references profile(did) on delete cascade
276
+
);
277
+
create table if not exists profile_pinned_repositories (
278
+
-- id
279
+
id integer primary key autoincrement,
280
+
did text not null,
281
+
282
+
-- data
283
+
at_uri text not null,
284
+
285
+
-- constraints
286
+
unique(did, at_uri),
287
+
foreign key (did) references profile(did) on delete cascade,
288
+
foreign key (at_uri) references repos(at_uri) on delete cascade
289
+
);
290
+
291
+
create table if not exists oauth_requests (
292
+
id integer primary key autoincrement,
293
+
auth_server_iss text not null,
294
+
state text not null,
295
+
did text not null,
296
+
handle text not null,
297
+
pds_url text not null,
298
+
pkce_verifier text not null,
299
+
dpop_auth_server_nonce text not null,
300
+
dpop_private_jwk text not null
301
+
);
302
+
303
+
create table if not exists oauth_sessions (
304
+
id integer primary key autoincrement,
305
+
did text not null,
306
+
handle text not null,
307
+
pds_url text not null,
308
+
auth_server_iss text not null,
309
+
access_jwt text not null,
310
+
refresh_jwt text not null,
311
+
dpop_pds_nonce text,
312
+
dpop_auth_server_nonce text not null,
313
+
dpop_private_jwk text not null,
314
+
expiry text not null
315
+
);
316
+
317
create table if not exists migrations (
318
id integer primary key autoincrement,
319
name text unique
···
354
return nil
355
})
356
357
+
runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error {
358
+
_, err := tx.Exec(`
359
+
alter table comments drop column comment_at;
360
+
alter table comments add column rkey text;
361
+
`)
362
+
return err
363
+
})
364
+
365
+
runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
366
+
_, err := tx.Exec(`
367
+
alter table comments add column deleted text; -- timestamp
368
+
alter table comments add column edited text; -- timestamp
369
+
`)
370
+
return err
371
+
})
372
+
373
+
runMigration(db, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
374
+
_, err := tx.Exec(`
375
+
alter table pulls add column source_branch text;
376
+
alter table pulls add column source_repo_at text;
377
+
alter table pull_submissions add column source_rev text;
378
+
`)
379
+
return err
380
+
})
381
+
382
+
runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error {
383
+
_, err := tx.Exec(`
384
+
alter table repos add column source text;
385
+
`)
386
+
return err
387
+
})
388
+
389
return &DB{db}, nil
390
}
391
···
431
432
return nil
433
}
434
+
435
+
type filter struct {
436
+
key string
437
+
arg any
438
+
}
439
+
440
+
func Filter(key string, arg any) filter {
441
+
return filter{
442
+
key: key,
443
+
arg: arg,
444
+
}
445
+
}
446
+
447
+
func (f filter) Condition() string {
448
+
return fmt.Sprintf("%s = ?", f.key)
449
+
}
+6
appview/db/follow.go
+6
appview/db/follow.go
···
47
return err
48
}
49
50
+
// Remove a follow
51
+
func DeleteFollowByRkey(e Execer, userDid, rkey string) error {
52
+
_, err := e.Exec(`delete from follows where user_did = ? and rkey = ?`, userDid, rkey)
53
+
return err
54
+
}
55
+
56
func GetFollowerFollowing(e Execer, did string) (int, int, error) {
57
followers, following := 0, 0
58
err := e.QueryRow(
+237
-24
appview/db/issues.go
+237
-24
appview/db/issues.go
···
5
"time"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
)
9
10
type Issue struct {
···
12
OwnerDid string
13
IssueId int
14
IssueAt string
15
-
Created *time.Time
16
Title string
17
Body string
18
Open bool
19
Metadata *IssueMetadata
20
}
21
22
type IssueMetadata struct {
23
CommentCount int
24
// labels, assignee etc.
25
}
26
27
type Comment struct {
28
OwnerDid string
29
RepoAt syntax.ATURI
30
-
CommentAt string
31
Issue int
32
CommentId int
33
Body string
34
Created *time.Time
35
}
36
37
func NewIssue(tx *sql.Tx, issue *Issue) error {
···
96
return ownerDid, err
97
}
98
99
-
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool) ([]Issue, error) {
100
var issues []Issue
101
openValue := 0
102
if isOpen {
···
104
}
105
106
rows, err := e.Query(
107
`select
108
i.owner_did,
109
i.issue_id,
110
i.created,
111
i.title,
112
i.body,
113
i.open,
114
-
count(c.id)
115
from
116
issues i
117
-
left join
118
-
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
119
-
where
120
-
i.repo_at = ? and i.open = ?
121
-
group by
122
-
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
123
order by
124
i.created desc`,
125
-
repoAt, openValue)
126
if err != nil {
127
return nil, err
128
}
···
130
131
for rows.Next() {
132
var issue Issue
133
-
var createdAt string
134
-
var metadata IssueMetadata
135
-
err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
136
if err != nil {
137
return nil, err
138
}
139
140
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
141
if err != nil {
142
return nil, err
143
}
144
-
issue.Created = &createdTime
145
-
issue.Metadata = &metadata
146
147
issues = append(issues, issue)
148
}
···
169
if err != nil {
170
return nil, err
171
}
172
-
issue.Created = &createdTime
173
174
return &issue, nil
175
}
···
189
if err != nil {
190
return nil, nil, err
191
}
192
-
issue.Created = &createdTime
193
194
comments, err := GetComments(e, repoAt, issueId)
195
if err != nil {
···
199
return &issue, comments, nil
200
}
201
202
-
func NewComment(e Execer, comment *Comment) error {
203
-
query := `insert into comments (owner_did, repo_at, comment_at, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
204
_, err := e.Exec(
205
query,
206
comment.OwnerDid,
207
comment.RepoAt,
208
-
comment.CommentAt,
209
comment.Issue,
210
comment.CommentId,
211
comment.Body,
···
216
func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) {
217
var comments []Comment
218
219
-
rows, err := e.Query(`select owner_did, issue_id, comment_id, comment_at, body, created from comments where repo_at = ? and issue_id = ? order by created asc`, repoAt, issueId)
220
if err == sql.ErrNoRows {
221
return []Comment{}, nil
222
}
···
228
for rows.Next() {
229
var comment Comment
230
var createdAt string
231
-
err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &comment.CommentAt, &comment.Body, &createdAt)
232
if err != nil {
233
return nil, err
234
}
···
239
}
240
comment.Created = &createdAtTime
241
242
comments = append(comments, comment)
243
}
244
···
247
}
248
249
return comments, nil
250
}
251
252
func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
···
5
"time"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"tangled.sh/tangled.sh/core/appview/pagination"
9
)
10
11
type Issue struct {
···
13
OwnerDid string
14
IssueId int
15
IssueAt string
16
+
Created time.Time
17
Title string
18
Body string
19
Open bool
20
+
21
+
// optionally, populate this when querying for reverse mappings
22
+
// like comment counts, parent repo etc.
23
Metadata *IssueMetadata
24
}
25
26
type IssueMetadata struct {
27
CommentCount int
28
+
Repo *Repo
29
// labels, assignee etc.
30
}
31
32
type Comment struct {
33
OwnerDid string
34
RepoAt syntax.ATURI
35
+
Rkey string
36
Issue int
37
CommentId int
38
Body string
39
Created *time.Time
40
+
Deleted *time.Time
41
+
Edited *time.Time
42
}
43
44
func NewIssue(tx *sql.Tx, issue *Issue) error {
···
103
return ownerDid, err
104
}
105
106
+
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
107
var issues []Issue
108
openValue := 0
109
if isOpen {
···
111
}
112
113
rows, err := e.Query(
114
+
`
115
+
with numbered_issue as (
116
+
select
117
+
i.owner_did,
118
+
i.issue_id,
119
+
i.created,
120
+
i.title,
121
+
i.body,
122
+
i.open,
123
+
count(c.id) as comment_count,
124
+
row_number() over (order by i.created desc) as row_num
125
+
from
126
+
issues i
127
+
left join
128
+
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
129
+
where
130
+
i.repo_at = ? and i.open = ?
131
+
group by
132
+
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
133
+
)
134
+
select
135
+
owner_did,
136
+
issue_id,
137
+
created,
138
+
title,
139
+
body,
140
+
open,
141
+
comment_count
142
+
from
143
+
numbered_issue
144
+
where
145
+
row_num between ? and ?`,
146
+
repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
147
+
if err != nil {
148
+
return nil, err
149
+
}
150
+
defer rows.Close()
151
+
152
+
for rows.Next() {
153
+
var issue Issue
154
+
var createdAt string
155
+
var metadata IssueMetadata
156
+
err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
157
+
if err != nil {
158
+
return nil, err
159
+
}
160
+
161
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
162
+
if err != nil {
163
+
return nil, err
164
+
}
165
+
issue.Created = createdTime
166
+
issue.Metadata = &metadata
167
+
168
+
issues = append(issues, issue)
169
+
}
170
+
171
+
if err := rows.Err(); err != nil {
172
+
return nil, err
173
+
}
174
+
175
+
return issues, nil
176
+
}
177
+
178
+
// timeframe here is directly passed into the sql query filter, and any
179
+
// timeframe in the past should be negative; e.g.: "-3 months"
180
+
func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
181
+
var issues []Issue
182
+
183
+
rows, err := e.Query(
184
`select
185
i.owner_did,
186
+
i.repo_at,
187
i.issue_id,
188
i.created,
189
i.title,
190
i.body,
191
i.open,
192
+
r.did,
193
+
r.name,
194
+
r.knot,
195
+
r.rkey,
196
+
r.created
197
from
198
issues i
199
+
join
200
+
repos r on i.repo_at = r.at_uri
201
+
where
202
+
i.owner_did = ? and i.created >= date ('now', ?)
203
order by
204
i.created desc`,
205
+
ownerDid, timeframe)
206
if err != nil {
207
return nil, err
208
}
···
210
211
for rows.Next() {
212
var issue Issue
213
+
var issueCreatedAt, repoCreatedAt string
214
+
var repo Repo
215
+
err := rows.Scan(
216
+
&issue.OwnerDid,
217
+
&issue.RepoAt,
218
+
&issue.IssueId,
219
+
&issueCreatedAt,
220
+
&issue.Title,
221
+
&issue.Body,
222
+
&issue.Open,
223
+
&repo.Did,
224
+
&repo.Name,
225
+
&repo.Knot,
226
+
&repo.Rkey,
227
+
&repoCreatedAt,
228
+
)
229
if err != nil {
230
return nil, err
231
}
232
233
+
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
234
if err != nil {
235
return nil, err
236
}
237
+
issue.Created = issueCreatedTime
238
+
239
+
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
240
+
if err != nil {
241
+
return nil, err
242
+
}
243
+
repo.Created = repoCreatedTime
244
+
245
+
issue.Metadata = &IssueMetadata{
246
+
Repo: &repo,
247
+
}
248
249
issues = append(issues, issue)
250
}
···
271
if err != nil {
272
return nil, err
273
}
274
+
issue.Created = createdTime
275
276
return &issue, nil
277
}
···
291
if err != nil {
292
return nil, nil, err
293
}
294
+
issue.Created = createdTime
295
296
comments, err := GetComments(e, repoAt, issueId)
297
if err != nil {
···
301
return &issue, comments, nil
302
}
303
304
+
func NewIssueComment(e Execer, comment *Comment) error {
305
+
query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
306
_, err := e.Exec(
307
query,
308
comment.OwnerDid,
309
comment.RepoAt,
310
+
comment.Rkey,
311
comment.Issue,
312
comment.CommentId,
313
comment.Body,
···
318
func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) {
319
var comments []Comment
320
321
+
rows, err := e.Query(`
322
+
select
323
+
owner_did,
324
+
issue_id,
325
+
comment_id,
326
+
rkey,
327
+
body,
328
+
created,
329
+
edited,
330
+
deleted
331
+
from
332
+
comments
333
+
where
334
+
repo_at = ? and issue_id = ?
335
+
order by
336
+
created asc`,
337
+
repoAt,
338
+
issueId,
339
+
)
340
if err == sql.ErrNoRows {
341
return []Comment{}, nil
342
}
···
348
for rows.Next() {
349
var comment Comment
350
var createdAt string
351
+
var deletedAt, editedAt, rkey sql.NullString
352
+
err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt)
353
if err != nil {
354
return nil, err
355
}
···
360
}
361
comment.Created = &createdAtTime
362
363
+
if deletedAt.Valid {
364
+
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
365
+
if err != nil {
366
+
return nil, err
367
+
}
368
+
comment.Deleted = &deletedTime
369
+
}
370
+
371
+
if editedAt.Valid {
372
+
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
373
+
if err != nil {
374
+
return nil, err
375
+
}
376
+
comment.Edited = &editedTime
377
+
}
378
+
379
+
if rkey.Valid {
380
+
comment.Rkey = rkey.String
381
+
}
382
+
383
comments = append(comments, comment)
384
}
385
···
388
}
389
390
return comments, nil
391
+
}
392
+
393
+
func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) {
394
+
query := `
395
+
select
396
+
owner_did, body, rkey, created, deleted, edited
397
+
from
398
+
comments where repo_at = ? and issue_id = ? and comment_id = ?
399
+
`
400
+
row := e.QueryRow(query, repoAt, issueId, commentId)
401
+
402
+
var comment Comment
403
+
var createdAt string
404
+
var deletedAt, editedAt, rkey sql.NullString
405
+
err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt)
406
+
if err != nil {
407
+
return nil, err
408
+
}
409
+
410
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
411
+
if err != nil {
412
+
return nil, err
413
+
}
414
+
comment.Created = &createdTime
415
+
416
+
if deletedAt.Valid {
417
+
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
418
+
if err != nil {
419
+
return nil, err
420
+
}
421
+
comment.Deleted = &deletedTime
422
+
}
423
+
424
+
if editedAt.Valid {
425
+
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
426
+
if err != nil {
427
+
return nil, err
428
+
}
429
+
comment.Edited = &editedTime
430
+
}
431
+
432
+
if rkey.Valid {
433
+
comment.Rkey = rkey.String
434
+
}
435
+
436
+
comment.RepoAt = repoAt
437
+
comment.Issue = issueId
438
+
comment.CommentId = commentId
439
+
440
+
return &comment, nil
441
+
}
442
+
443
+
func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error {
444
+
_, err := e.Exec(
445
+
`
446
+
update comments
447
+
set body = ?,
448
+
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
449
+
where repo_at = ? and issue_id = ? and comment_id = ?
450
+
`, newBody, repoAt, issueId, commentId)
451
+
return err
452
+
}
453
+
454
+
func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error {
455
+
_, err := e.Exec(
456
+
`
457
+
update comments
458
+
set body = "",
459
+
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
460
+
where repo_at = ? and issue_id = ? and comment_id = ?
461
+
`, repoAt, issueId, commentId)
462
+
return err
463
}
464
465
func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
+6
-10
appview/db/jetstream.go
+6
-10
appview/db/jetstream.go
···
5
}
6
7
func (db DbWrapper) SaveLastTimeUs(lastTimeUs int64) error {
8
-
_, err := db.Exec(`insert into _jetstream (last_time_us) values (?)`, lastTimeUs)
9
return err
10
}
11
12
-
func (db DbWrapper) UpdateLastTimeUs(lastTimeUs int64) error {
13
-
_, err := db.Exec(`update _jetstream set last_time_us = ? where rowid = 1`, lastTimeUs)
14
-
if err != nil {
15
-
return err
16
-
}
17
-
return nil
18
-
}
19
-
20
func (db DbWrapper) GetLastTimeUs() (int64, error) {
21
var lastTimeUs int64
22
-
row := db.QueryRow(`select last_time_us from _jetstream`)
23
err := row.Scan(&lastTimeUs)
24
return lastTimeUs, err
25
}
···
5
}
6
7
func (db DbWrapper) SaveLastTimeUs(lastTimeUs int64) error {
8
+
_, err := db.Exec(`
9
+
insert into _jetstream (id, last_time_us)
10
+
values (1, ?)
11
+
on conflict(id) do update set last_time_us = excluded.last_time_us
12
+
`, lastTimeUs)
13
return err
14
}
15
16
func (db DbWrapper) GetLastTimeUs() (int64, error) {
17
var lastTimeUs int64
18
+
row := db.QueryRow(`select last_time_us from _jetstream where id = 1;`)
19
err := row.Scan(&lastTimeUs)
20
return lastTimeUs, err
21
}
+173
appview/db/oauth.go
+173
appview/db/oauth.go
···
···
1
+
package db
2
+
3
+
type OAuthRequest struct {
4
+
ID uint
5
+
AuthserverIss string
6
+
Handle string
7
+
State string
8
+
Did string
9
+
PdsUrl string
10
+
PkceVerifier string
11
+
DpopAuthserverNonce string
12
+
DpopPrivateJwk string
13
+
}
14
+
15
+
func SaveOAuthRequest(e Execer, oauthRequest OAuthRequest) error {
16
+
_, err := e.Exec(`
17
+
insert into oauth_requests (
18
+
auth_server_iss,
19
+
state,
20
+
handle,
21
+
did,
22
+
pds_url,
23
+
pkce_verifier,
24
+
dpop_auth_server_nonce,
25
+
dpop_private_jwk
26
+
) values (?, ?, ?, ?, ?, ?, ?, ?)`,
27
+
oauthRequest.AuthserverIss,
28
+
oauthRequest.State,
29
+
oauthRequest.Handle,
30
+
oauthRequest.Did,
31
+
oauthRequest.PdsUrl,
32
+
oauthRequest.PkceVerifier,
33
+
oauthRequest.DpopAuthserverNonce,
34
+
oauthRequest.DpopPrivateJwk,
35
+
)
36
+
return err
37
+
}
38
+
39
+
func GetOAuthRequestByState(e Execer, state string) (OAuthRequest, error) {
40
+
var req OAuthRequest
41
+
err := e.QueryRow(`
42
+
select
43
+
id,
44
+
auth_server_iss,
45
+
handle,
46
+
state,
47
+
did,
48
+
pds_url,
49
+
pkce_verifier,
50
+
dpop_auth_server_nonce,
51
+
dpop_private_jwk
52
+
from oauth_requests
53
+
where state = ?`, state).Scan(
54
+
&req.ID,
55
+
&req.AuthserverIss,
56
+
&req.Handle,
57
+
&req.State,
58
+
&req.Did,
59
+
&req.PdsUrl,
60
+
&req.PkceVerifier,
61
+
&req.DpopAuthserverNonce,
62
+
&req.DpopPrivateJwk,
63
+
)
64
+
return req, err
65
+
}
66
+
67
+
func DeleteOAuthRequestByState(e Execer, state string) error {
68
+
_, err := e.Exec(`
69
+
delete from oauth_requests
70
+
where state = ?`, state)
71
+
return err
72
+
}
73
+
74
+
type OAuthSession struct {
75
+
ID uint
76
+
Handle string
77
+
Did string
78
+
PdsUrl string
79
+
AccessJwt string
80
+
RefreshJwt string
81
+
AuthServerIss string
82
+
DpopPdsNonce string
83
+
DpopAuthserverNonce string
84
+
DpopPrivateJwk string
85
+
Expiry string
86
+
}
87
+
88
+
func SaveOAuthSession(e Execer, session OAuthSession) error {
89
+
_, err := e.Exec(`
90
+
insert into oauth_sessions (
91
+
did,
92
+
handle,
93
+
pds_url,
94
+
access_jwt,
95
+
refresh_jwt,
96
+
auth_server_iss,
97
+
dpop_auth_server_nonce,
98
+
dpop_private_jwk,
99
+
expiry
100
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
101
+
session.Did,
102
+
session.Handle,
103
+
session.PdsUrl,
104
+
session.AccessJwt,
105
+
session.RefreshJwt,
106
+
session.AuthServerIss,
107
+
session.DpopAuthserverNonce,
108
+
session.DpopPrivateJwk,
109
+
session.Expiry,
110
+
)
111
+
return err
112
+
}
113
+
114
+
func RefreshOAuthSession(e Execer, did string, accessJwt, refreshJwt, expiry string) error {
115
+
_, err := e.Exec(`
116
+
update oauth_sessions
117
+
set access_jwt = ?, refresh_jwt = ?, expiry = ?
118
+
where did = ?`,
119
+
accessJwt,
120
+
refreshJwt,
121
+
expiry,
122
+
did,
123
+
)
124
+
return err
125
+
}
126
+
127
+
func GetOAuthSessionByDid(e Execer, did string) (*OAuthSession, error) {
128
+
var session OAuthSession
129
+
err := e.QueryRow(`
130
+
select
131
+
id,
132
+
did,
133
+
handle,
134
+
pds_url,
135
+
access_jwt,
136
+
refresh_jwt,
137
+
auth_server_iss,
138
+
dpop_auth_server_nonce,
139
+
dpop_private_jwk,
140
+
expiry
141
+
from oauth_sessions
142
+
where did = ?`, did).Scan(
143
+
&session.ID,
144
+
&session.Did,
145
+
&session.Handle,
146
+
&session.PdsUrl,
147
+
&session.AccessJwt,
148
+
&session.RefreshJwt,
149
+
&session.AuthServerIss,
150
+
&session.DpopAuthserverNonce,
151
+
&session.DpopPrivateJwk,
152
+
&session.Expiry,
153
+
)
154
+
return &session, err
155
+
}
156
+
157
+
func DeleteOAuthSessionByDid(e Execer, did string) error {
158
+
_, err := e.Exec(`
159
+
delete from oauth_sessions
160
+
where did = ?`, did)
161
+
return err
162
+
}
163
+
164
+
func UpdateDpopPdsNonce(e Execer, did string, dpopPdsNonce string) error {
165
+
_, err := e.Exec(`
166
+
update oauth_sessions
167
+
set dpop_pds_nonce = ?
168
+
where did = ?`,
169
+
dpopPdsNonce,
170
+
did,
171
+
)
172
+
return err
173
+
}
+530
appview/db/profile.go
+530
appview/db/profile.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"database/sql"
5
+
"fmt"
6
+
"log"
7
+
"net/url"
8
+
"slices"
9
+
"strings"
10
+
"time"
11
+
12
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.sh/tangled.sh/core/api/tangled"
14
+
)
15
+
16
+
type RepoEvent struct {
17
+
Repo *Repo
18
+
Source *Repo
19
+
}
20
+
21
+
type ProfileTimeline struct {
22
+
ByMonth []ByMonth
23
+
}
24
+
25
+
type ByMonth struct {
26
+
RepoEvents []RepoEvent
27
+
IssueEvents IssueEvents
28
+
PullEvents PullEvents
29
+
}
30
+
31
+
func (b ByMonth) IsEmpty() bool {
32
+
return len(b.RepoEvents) == 0 &&
33
+
len(b.IssueEvents.Items) == 0 &&
34
+
len(b.PullEvents.Items) == 0
35
+
}
36
+
37
+
type IssueEvents struct {
38
+
Items []*Issue
39
+
}
40
+
41
+
type IssueEventStats struct {
42
+
Open int
43
+
Closed int
44
+
}
45
+
46
+
func (i IssueEvents) Stats() IssueEventStats {
47
+
var open, closed int
48
+
for _, issue := range i.Items {
49
+
if issue.Open {
50
+
open += 1
51
+
} else {
52
+
closed += 1
53
+
}
54
+
}
55
+
56
+
return IssueEventStats{
57
+
Open: open,
58
+
Closed: closed,
59
+
}
60
+
}
61
+
62
+
type PullEvents struct {
63
+
Items []*Pull
64
+
}
65
+
66
+
func (p PullEvents) Stats() PullEventStats {
67
+
var open, merged, closed int
68
+
for _, pull := range p.Items {
69
+
switch pull.State {
70
+
case PullOpen:
71
+
open += 1
72
+
case PullMerged:
73
+
merged += 1
74
+
case PullClosed:
75
+
closed += 1
76
+
}
77
+
}
78
+
79
+
return PullEventStats{
80
+
Open: open,
81
+
Merged: merged,
82
+
Closed: closed,
83
+
}
84
+
}
85
+
86
+
type PullEventStats struct {
87
+
Closed int
88
+
Open int
89
+
Merged int
90
+
}
91
+
92
+
const TimeframeMonths = 7
93
+
94
+
func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) {
95
+
timeline := ProfileTimeline{
96
+
ByMonth: make([]ByMonth, TimeframeMonths),
97
+
}
98
+
currentMonth := time.Now().Month()
99
+
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
100
+
101
+
pulls, err := GetPullsByOwnerDid(e, forDid, timeframe)
102
+
if err != nil {
103
+
return nil, fmt.Errorf("error getting pulls by owner did: %w", err)
104
+
}
105
+
106
+
// group pulls by month
107
+
for _, pull := range pulls {
108
+
pullMonth := pull.Created.Month()
109
+
110
+
if currentMonth-pullMonth >= TimeframeMonths {
111
+
// shouldn't happen; but times are weird
112
+
continue
113
+
}
114
+
115
+
idx := currentMonth - pullMonth
116
+
items := &timeline.ByMonth[idx].PullEvents.Items
117
+
118
+
*items = append(*items, &pull)
119
+
}
120
+
121
+
issues, err := GetIssuesByOwnerDid(e, forDid, timeframe)
122
+
if err != nil {
123
+
return nil, fmt.Errorf("error getting issues by owner did: %w", err)
124
+
}
125
+
126
+
for _, issue := range issues {
127
+
issueMonth := issue.Created.Month()
128
+
129
+
if currentMonth-issueMonth >= TimeframeMonths {
130
+
// shouldn't happen; but times are weird
131
+
continue
132
+
}
133
+
134
+
idx := currentMonth - issueMonth
135
+
items := &timeline.ByMonth[idx].IssueEvents.Items
136
+
137
+
*items = append(*items, &issue)
138
+
}
139
+
140
+
repos, err := GetAllReposByDid(e, forDid)
141
+
if err != nil {
142
+
return nil, fmt.Errorf("error getting all repos by did: %w", err)
143
+
}
144
+
145
+
for _, repo := range repos {
146
+
// TODO: get this in the original query; requires COALESCE because nullable
147
+
var sourceRepo *Repo
148
+
if repo.Source != "" {
149
+
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
150
+
if err != nil {
151
+
return nil, err
152
+
}
153
+
}
154
+
155
+
repoMonth := repo.Created.Month()
156
+
157
+
if currentMonth-repoMonth >= TimeframeMonths {
158
+
// shouldn't happen; but times are weird
159
+
continue
160
+
}
161
+
162
+
idx := currentMonth - repoMonth
163
+
164
+
items := &timeline.ByMonth[idx].RepoEvents
165
+
*items = append(*items, RepoEvent{
166
+
Repo: &repo,
167
+
Source: sourceRepo,
168
+
})
169
+
}
170
+
171
+
return &timeline, nil
172
+
}
173
+
174
+
type Profile struct {
175
+
// ids
176
+
ID int
177
+
Did string
178
+
179
+
// data
180
+
Description string
181
+
IncludeBluesky bool
182
+
Location string
183
+
Links [5]string
184
+
Stats [2]VanityStat
185
+
PinnedRepos [6]syntax.ATURI
186
+
}
187
+
188
+
func (p Profile) IsLinksEmpty() bool {
189
+
for _, l := range p.Links {
190
+
if l != "" {
191
+
return false
192
+
}
193
+
}
194
+
return true
195
+
}
196
+
197
+
func (p Profile) IsStatsEmpty() bool {
198
+
for _, s := range p.Stats {
199
+
if s.Kind != "" {
200
+
return false
201
+
}
202
+
}
203
+
return true
204
+
}
205
+
206
+
func (p Profile) IsPinnedReposEmpty() bool {
207
+
for _, r := range p.PinnedRepos {
208
+
if r != "" {
209
+
return false
210
+
}
211
+
}
212
+
return true
213
+
}
214
+
215
+
type VanityStatKind string
216
+
217
+
const (
218
+
VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count"
219
+
VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count"
220
+
VanityStatOpenPRCount VanityStatKind = "open-pull-request-count"
221
+
VanityStatOpenIssueCount VanityStatKind = "open-issue-count"
222
+
VanityStatClosedIssueCount VanityStatKind = "closed-issue-count"
223
+
VanityStatRepositoryCount VanityStatKind = "repository-count"
224
+
)
225
+
226
+
func (v VanityStatKind) String() string {
227
+
switch v {
228
+
case VanityStatMergedPRCount:
229
+
return "Merged PRs"
230
+
case VanityStatClosedPRCount:
231
+
return "Closed PRs"
232
+
case VanityStatOpenPRCount:
233
+
return "Open PRs"
234
+
case VanityStatOpenIssueCount:
235
+
return "Open Issues"
236
+
case VanityStatClosedIssueCount:
237
+
return "Closed Issues"
238
+
case VanityStatRepositoryCount:
239
+
return "Repositories"
240
+
}
241
+
return ""
242
+
}
243
+
244
+
type VanityStat struct {
245
+
Kind VanityStatKind
246
+
Value uint64
247
+
}
248
+
249
+
func (p *Profile) ProfileAt() syntax.ATURI {
250
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self"))
251
+
}
252
+
253
+
func UpsertProfile(tx *sql.Tx, profile *Profile) error {
254
+
defer tx.Rollback()
255
+
256
+
// update links
257
+
_, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did)
258
+
if err != nil {
259
+
return err
260
+
}
261
+
// update vanity stats
262
+
_, err = tx.Exec(`delete from profile_stats where did = ?`, profile.Did)
263
+
if err != nil {
264
+
return err
265
+
}
266
+
267
+
// update pinned repos
268
+
_, err = tx.Exec(`delete from profile_pinned_repositories where did = ?`, profile.Did)
269
+
if err != nil {
270
+
return err
271
+
}
272
+
273
+
includeBskyValue := 0
274
+
if profile.IncludeBluesky {
275
+
includeBskyValue = 1
276
+
}
277
+
278
+
_, err = tx.Exec(
279
+
`insert or replace into profile (
280
+
did,
281
+
description,
282
+
include_bluesky,
283
+
location
284
+
)
285
+
values (?, ?, ?, ?)`,
286
+
profile.Did,
287
+
profile.Description,
288
+
includeBskyValue,
289
+
profile.Location,
290
+
)
291
+
292
+
if err != nil {
293
+
log.Println("profile", "err", err)
294
+
return err
295
+
}
296
+
297
+
for _, link := range profile.Links {
298
+
if link == "" {
299
+
continue
300
+
}
301
+
302
+
_, err := tx.Exec(
303
+
`insert into profile_links (did, link) values (?, ?)`,
304
+
profile.Did,
305
+
link,
306
+
)
307
+
308
+
if err != nil {
309
+
log.Println("profile_links", "err", err)
310
+
return err
311
+
}
312
+
}
313
+
314
+
for _, v := range profile.Stats {
315
+
if v.Kind == "" {
316
+
continue
317
+
}
318
+
319
+
_, err := tx.Exec(
320
+
`insert into profile_stats (did, kind) values (?, ?)`,
321
+
profile.Did,
322
+
v.Kind,
323
+
)
324
+
325
+
if err != nil {
326
+
log.Println("profile_stats", "err", err)
327
+
return err
328
+
}
329
+
}
330
+
331
+
for _, pin := range profile.PinnedRepos {
332
+
if pin == "" {
333
+
continue
334
+
}
335
+
336
+
_, err := tx.Exec(
337
+
`insert into profile_pinned_repositories (did, at_uri) values (?, ?)`,
338
+
profile.Did,
339
+
pin,
340
+
)
341
+
342
+
if err != nil {
343
+
log.Println("profile_pinned_repositories", "err", err)
344
+
return err
345
+
}
346
+
}
347
+
348
+
return tx.Commit()
349
+
}
350
+
351
+
func GetProfile(e Execer, did string) (*Profile, error) {
352
+
var profile Profile
353
+
profile.Did = did
354
+
355
+
includeBluesky := 0
356
+
err := e.QueryRow(
357
+
`select description, include_bluesky, location from profile where did = ?`,
358
+
did,
359
+
).Scan(&profile.Description, &includeBluesky, &profile.Location)
360
+
if err == sql.ErrNoRows {
361
+
profile := Profile{}
362
+
profile.Did = did
363
+
return &profile, nil
364
+
}
365
+
366
+
if err != nil {
367
+
return nil, err
368
+
}
369
+
370
+
if includeBluesky != 0 {
371
+
profile.IncludeBluesky = true
372
+
}
373
+
374
+
rows, err := e.Query(`select link from profile_links where did = ?`, did)
375
+
if err != nil {
376
+
return nil, err
377
+
}
378
+
defer rows.Close()
379
+
i := 0
380
+
for rows.Next() {
381
+
if err := rows.Scan(&profile.Links[i]); err != nil {
382
+
return nil, err
383
+
}
384
+
i++
385
+
}
386
+
387
+
rows, err = e.Query(`select kind from profile_stats where did = ?`, did)
388
+
if err != nil {
389
+
return nil, err
390
+
}
391
+
defer rows.Close()
392
+
i = 0
393
+
for rows.Next() {
394
+
if err := rows.Scan(&profile.Stats[i].Kind); err != nil {
395
+
return nil, err
396
+
}
397
+
value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind)
398
+
if err != nil {
399
+
return nil, err
400
+
}
401
+
profile.Stats[i].Value = value
402
+
i++
403
+
}
404
+
405
+
rows, err = e.Query(`select at_uri from profile_pinned_repositories where did = ?`, did)
406
+
if err != nil {
407
+
return nil, err
408
+
}
409
+
defer rows.Close()
410
+
i = 0
411
+
for rows.Next() {
412
+
if err := rows.Scan(&profile.PinnedRepos[i]); err != nil {
413
+
return nil, err
414
+
}
415
+
i++
416
+
}
417
+
418
+
return &profile, nil
419
+
}
420
+
421
+
func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) {
422
+
query := ""
423
+
var args []any
424
+
switch stat {
425
+
case VanityStatMergedPRCount:
426
+
query = `select count(id) from pulls where owner_did = ? and state = ?`
427
+
args = append(args, did, PullMerged)
428
+
case VanityStatClosedPRCount:
429
+
query = `select count(id) from pulls where owner_did = ? and state = ?`
430
+
args = append(args, did, PullClosed)
431
+
case VanityStatOpenPRCount:
432
+
query = `select count(id) from pulls where owner_did = ? and state = ?`
433
+
args = append(args, did, PullOpen)
434
+
case VanityStatOpenIssueCount:
435
+
query = `select count(id) from issues where owner_did = ? and open = 1`
436
+
args = append(args, did)
437
+
case VanityStatClosedIssueCount:
438
+
query = `select count(id) from issues where owner_did = ? and open = 0`
439
+
args = append(args, did)
440
+
case VanityStatRepositoryCount:
441
+
query = `select count(id) from repos where did = ?`
442
+
args = append(args, did)
443
+
}
444
+
445
+
var result uint64
446
+
err := e.QueryRow(query, args...).Scan(&result)
447
+
if err != nil {
448
+
return 0, err
449
+
}
450
+
451
+
return result, nil
452
+
}
453
+
454
+
func ValidateProfile(e Execer, profile *Profile) error {
455
+
// ensure description is not too long
456
+
if len(profile.Description) > 256 {
457
+
return fmt.Errorf("Entered bio is too long.")
458
+
}
459
+
460
+
// ensure description is not too long
461
+
if len(profile.Location) > 40 {
462
+
return fmt.Errorf("Entered location is too long.")
463
+
}
464
+
465
+
// ensure links are in order
466
+
err := validateLinks(profile)
467
+
if err != nil {
468
+
return err
469
+
}
470
+
471
+
// ensure all pinned repos are either own repos or collaborating repos
472
+
repos, err := GetAllReposByDid(e, profile.Did)
473
+
if err != nil {
474
+
log.Printf("getting repos for %s: %s", profile.Did, err)
475
+
}
476
+
477
+
collaboratingRepos, err := CollaboratingIn(e, profile.Did)
478
+
if err != nil {
479
+
log.Printf("getting collaborating repos for %s: %s", profile.Did, err)
480
+
}
481
+
482
+
var validRepos []syntax.ATURI
483
+
for _, r := range repos {
484
+
validRepos = append(validRepos, r.RepoAt())
485
+
}
486
+
for _, r := range collaboratingRepos {
487
+
validRepos = append(validRepos, r.RepoAt())
488
+
}
489
+
490
+
for _, pinned := range profile.PinnedRepos {
491
+
if pinned == "" {
492
+
continue
493
+
}
494
+
if !slices.Contains(validRepos, pinned) {
495
+
return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned)
496
+
}
497
+
}
498
+
499
+
return nil
500
+
}
501
+
502
+
func validateLinks(profile *Profile) error {
503
+
for i, link := range profile.Links {
504
+
if link == "" {
505
+
continue
506
+
}
507
+
508
+
parsedURL, err := url.Parse(link)
509
+
if err != nil {
510
+
return fmt.Errorf("Invalid URL '%s': %v\n", link, err)
511
+
}
512
+
513
+
if parsedURL.Scheme == "" {
514
+
if strings.HasPrefix(link, "//") {
515
+
profile.Links[i] = "https:" + link
516
+
} else {
517
+
profile.Links[i] = "https://" + link
518
+
}
519
+
continue
520
+
} else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
521
+
return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme)
522
+
}
523
+
524
+
// catch relative paths
525
+
if parsedURL.Host == "" {
526
+
return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link)
527
+
}
528
+
}
529
+
return nil
530
+
}
+9
-1
appview/db/pubkeys.go
+9
-1
appview/db/pubkeys.go
···
13
return err
14
}
15
16
+
func DeletePublicKey(e Execer, did, name, key string) error {
17
_, err := e.Exec(`
18
delete from public_keys
19
where did = ? and name = ? and key = ?`,
20
did, name, key)
21
+
return err
22
+
}
23
+
24
+
func DeletePublicKeyByRkey(e Execer, did, rkey string) error {
25
+
_, err := e.Exec(`
26
+
delete from public_keys
27
+
where did = ? and rkey = ?`,
28
+
did, rkey)
29
return err
30
}
31
+351
-47
appview/db/pulls.go
+351
-47
appview/db/pulls.go
···
4
"database/sql"
5
"fmt"
6
"log"
7
"strings"
8
"time"
9
10
"github.com/bluekeyes/go-gitdiff/gitdiff"
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
"tangled.sh/tangled.sh/core/types"
13
)
14
···
52
RepoAt syntax.ATURI
53
OwnerDid string
54
Rkey string
55
-
PullAt syntax.ATURI
56
57
// content
58
Title string
···
62
Submissions []*PullSubmission
63
64
// meta
65
-
Created time.Time
66
}
67
68
type PullSubmission struct {
···
77
RoundNumber int
78
Patch string
79
Comments []PullComment
80
81
// meta
82
Created time.Time
···
105
return latestSubmission.Patch
106
}
107
108
func (p *Pull) LastRoundNumber() int {
109
return len(p.Submissions) - 1
110
}
111
112
-
func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {
113
patch := s.Patch
114
115
-
diffs, _, err := gitdiff.Parse(strings.NewReader(patch))
116
if err != nil {
117
log.Println(err)
118
}
···
150
return nd
151
}
152
153
-
func NewPull(tx *sql.Tx, pull *Pull) error {
154
-
defer tx.Rollback()
155
156
_, err := tx.Exec(`
157
insert or ignore into repo_pull_seqs (repo_at, next_pull_id)
158
values (?, 1)
···
175
pull.PullId = nextId
176
pull.State = PullOpen
177
178
-
_, err = tx.Exec(`
179
-
insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, rkey, state)
180
-
values (?, ?, ?, ?, ?, ?, ?, ?)
181
-
`, pull.RepoAt, pull.OwnerDid, pull.PullId, pull.Title, pull.TargetBranch, pull.Body, pull.Rkey, pull.State)
182
-
if err != nil {
183
-
return err
184
}
185
186
-
_, err = tx.Exec(`
187
-
insert into pull_submissions (pull_id, repo_at, round_number, patch)
188
-
values (?, ?, ?, ?)
189
-
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch)
190
if err != nil {
191
return err
192
}
193
194
-
if err := tx.Commit(); err != nil {
195
-
return err
196
-
}
197
-
198
-
return nil
199
-
}
200
-
201
-
func SetPullAt(e Execer, repoAt syntax.ATURI, pullId int, pullAt string) error {
202
-
_, err := e.Exec(`update pulls set pull_at = ? where repo_at = ? and pull_id = ?`, pullAt, repoAt, pullId)
203
return err
204
}
205
206
-
func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (string, error) {
207
-
var pullAt string
208
-
err := e.QueryRow(`select pull_at from pulls where repo_at = ? and pull_id = ?`, repoAt, pullId).Scan(&pullAt)
209
-
return pullAt, err
210
}
211
212
func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) {
···
215
return pullId - 1, err
216
}
217
218
-
func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]Pull, error) {
219
-
var pulls []Pull
220
221
rows, err := e.Query(`
222
select
···
226
title,
227
state,
228
target_branch,
229
-
pull_at,
230
body,
231
-
rkey
232
from
233
pulls
234
where
235
-
repo_at = ? and state = ?
236
-
order by
237
-
created desc`, repoAt, state)
238
if err != nil {
239
return nil, err
240
}
···
243
for rows.Next() {
244
var pull Pull
245
var createdAt string
246
err := rows.Scan(
247
&pull.OwnerDid,
248
&pull.PullId,
···
250
&pull.Title,
251
&pull.State,
252
&pull.TargetBranch,
253
-
&pull.PullAt,
254
&pull.Body,
255
&pull.Rkey,
256
)
257
if err != nil {
258
return nil, err
···
264
}
265
pull.Created = createdTime
266
267
-
pulls = append(pulls, pull)
268
}
269
270
if err := rows.Err(); err != nil {
271
return nil, err
272
}
273
274
-
return pulls, nil
275
}
276
277
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
···
283
title,
284
state,
285
target_branch,
286
-
pull_at,
287
repo_at,
288
body,
289
-
rkey
290
from
291
pulls
292
where
···
296
297
var pull Pull
298
var createdAt string
299
err := row.Scan(
300
&pull.OwnerDid,
301
&pull.PullId,
···
303
&pull.Title,
304
&pull.State,
305
&pull.TargetBranch,
306
-
&pull.PullAt,
307
&pull.RepoAt,
308
&pull.Body,
309
&pull.Rkey,
310
)
311
if err != nil {
312
return nil, err
···
318
}
319
pull.Created = createdTime
320
321
submissionsQuery := `
322
select
323
-
id, pull_id, repo_at, round_number, patch, created
324
from
325
pull_submissions
326
where
···
337
for submissionsRows.Next() {
338
var submission PullSubmission
339
var submissionCreatedStr string
340
err := submissionsRows.Scan(
341
&submission.ID,
342
&submission.PullId,
···
344
&submission.RoundNumber,
345
&submission.Patch,
346
&submissionCreatedStr,
347
)
348
if err != nil {
349
return nil, err
···
355
}
356
submission.Created = submissionCreatedTime
357
358
submissionsMap[submission.ID] = &submission
359
}
360
if err = submissionsRows.Close(); err != nil {
···
425
return nil, err
426
}
427
428
pull.Submissions = make([]*PullSubmission, len(submissionsMap))
429
for _, submission := range submissionsMap {
430
pull.Submissions[submission.RoundNumber] = submission
···
433
return &pull, nil
434
}
435
436
func NewPullComment(e Execer, comment *PullComment) (int64, error) {
437
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
438
res, err := e.Exec(
···
476
return err
477
}
478
479
-
func ResubmitPull(e Execer, pull *Pull, newPatch string) error {
480
newRoundNumber := len(pull.Submissions)
481
_, err := e.Exec(`
482
-
insert into pull_submissions (pull_id, repo_at, round_number, patch)
483
-
values (?, ?, ?, ?)
484
-
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch)
485
486
return err
487
}
···
4
"database/sql"
5
"fmt"
6
"log"
7
+
"sort"
8
"strings"
9
"time"
10
11
"github.com/bluekeyes/go-gitdiff/gitdiff"
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.sh/tangled.sh/core/api/tangled"
14
+
"tangled.sh/tangled.sh/core/patchutil"
15
"tangled.sh/tangled.sh/core/types"
16
)
17
···
55
RepoAt syntax.ATURI
56
OwnerDid string
57
Rkey string
58
59
// content
60
Title string
···
64
Submissions []*PullSubmission
65
66
// meta
67
+
Created time.Time
68
+
PullSource *PullSource
69
+
70
+
// optionally, populate this when querying for reverse mappings
71
+
Repo *Repo
72
+
}
73
+
74
+
type PullSource struct {
75
+
Branch string
76
+
RepoAt *syntax.ATURI
77
+
78
+
// optionally populate this for reverse mappings
79
+
Repo *Repo
80
}
81
82
type PullSubmission struct {
···
91
RoundNumber int
92
Patch string
93
Comments []PullComment
94
+
SourceRev string // include the rev that was used to create this submission: only for branch PRs
95
96
// meta
97
Created time.Time
···
120
return latestSubmission.Patch
121
}
122
123
+
func (p *Pull) PullAt() syntax.ATURI {
124
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
125
+
}
126
+
127
func (p *Pull) LastRoundNumber() int {
128
return len(p.Submissions) - 1
129
}
130
131
+
func (p *Pull) IsPatchBased() bool {
132
+
return p.PullSource == nil
133
+
}
134
+
135
+
func (p *Pull) IsBranchBased() bool {
136
+
if p.PullSource != nil {
137
+
if p.PullSource.RepoAt != nil {
138
+
return p.PullSource.RepoAt == &p.RepoAt
139
+
} else {
140
+
// no repo specified
141
+
return true
142
+
}
143
+
}
144
+
return false
145
+
}
146
+
147
+
func (p *Pull) IsForkBased() bool {
148
+
if p.PullSource != nil {
149
+
if p.PullSource.RepoAt != nil {
150
+
// make sure repos are different
151
+
return p.PullSource.RepoAt != &p.RepoAt
152
+
}
153
+
}
154
+
return false
155
+
}
156
+
157
+
func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) {
158
patch := s.Patch
159
160
+
// if format-patch; then extract each patch
161
+
var diffs []*gitdiff.File
162
+
if patchutil.IsFormatPatch(patch) {
163
+
patches, err := patchutil.ExtractPatches(patch)
164
+
if err != nil {
165
+
return nil, err
166
+
}
167
+
var ps [][]*gitdiff.File
168
+
for _, p := range patches {
169
+
ps = append(ps, p.Files)
170
+
}
171
+
172
+
diffs = patchutil.CombineDiff(ps...)
173
+
} else {
174
+
d, _, err := gitdiff.Parse(strings.NewReader(patch))
175
+
if err != nil {
176
+
return nil, err
177
+
}
178
+
diffs = d
179
+
}
180
+
181
+
return diffs, nil
182
+
}
183
+
184
+
func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {
185
+
diffs, err := s.AsDiff(targetBranch)
186
if err != nil {
187
log.Println(err)
188
}
···
220
return nd
221
}
222
223
+
func (s PullSubmission) IsFormatPatch() bool {
224
+
return patchutil.IsFormatPatch(s.Patch)
225
+
}
226
+
227
+
func (s PullSubmission) AsFormatPatch() []patchutil.FormatPatch {
228
+
patches, err := patchutil.ExtractPatches(s.Patch)
229
+
if err != nil {
230
+
log.Println("error extracting patches from submission:", err)
231
+
return []patchutil.FormatPatch{}
232
+
}
233
234
+
return patches
235
+
}
236
+
237
+
func NewPull(tx *sql.Tx, pull *Pull) error {
238
_, err := tx.Exec(`
239
insert or ignore into repo_pull_seqs (repo_at, next_pull_id)
240
values (?, 1)
···
257
pull.PullId = nextId
258
pull.State = PullOpen
259
260
+
var sourceBranch, sourceRepoAt *string
261
+
if pull.PullSource != nil {
262
+
sourceBranch = &pull.PullSource.Branch
263
+
if pull.PullSource.RepoAt != nil {
264
+
x := pull.PullSource.RepoAt.String()
265
+
sourceRepoAt = &x
266
+
}
267
}
268
269
+
_, err = tx.Exec(
270
+
`
271
+
insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at)
272
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
273
+
pull.RepoAt,
274
+
pull.OwnerDid,
275
+
pull.PullId,
276
+
pull.Title,
277
+
pull.TargetBranch,
278
+
pull.Body,
279
+
pull.Rkey,
280
+
pull.State,
281
+
sourceBranch,
282
+
sourceRepoAt,
283
+
)
284
if err != nil {
285
return err
286
}
287
288
+
_, err = tx.Exec(`
289
+
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
290
+
values (?, ?, ?, ?, ?)
291
+
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
292
return err
293
}
294
295
+
func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) {
296
+
pull, err := GetPull(e, repoAt, pullId)
297
+
if err != nil {
298
+
return "", err
299
+
}
300
+
return pull.PullAt(), err
301
}
302
303
func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) {
···
306
return pullId - 1, err
307
}
308
309
+
func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) {
310
+
pulls := make(map[int]*Pull)
311
312
rows, err := e.Query(`
313
select
···
317
title,
318
state,
319
target_branch,
320
body,
321
+
rkey,
322
+
source_branch,
323
+
source_repo_at
324
from
325
pulls
326
where
327
+
repo_at = ? and state = ?`, repoAt, state)
328
if err != nil {
329
return nil, err
330
}
···
333
for rows.Next() {
334
var pull Pull
335
var createdAt string
336
+
var sourceBranch, sourceRepoAt sql.NullString
337
err := rows.Scan(
338
&pull.OwnerDid,
339
&pull.PullId,
···
341
&pull.Title,
342
&pull.State,
343
&pull.TargetBranch,
344
&pull.Body,
345
&pull.Rkey,
346
+
&sourceBranch,
347
+
&sourceRepoAt,
348
)
349
if err != nil {
350
return nil, err
···
356
}
357
pull.Created = createdTime
358
359
+
if sourceBranch.Valid {
360
+
pull.PullSource = &PullSource{
361
+
Branch: sourceBranch.String,
362
+
}
363
+
if sourceRepoAt.Valid {
364
+
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
365
+
if err != nil {
366
+
return nil, err
367
+
}
368
+
pull.PullSource.RepoAt = &sourceRepoAtParsed
369
+
}
370
+
}
371
+
372
+
pulls[pull.PullId] = &pull
373
}
374
375
+
// get latest round no. for each pull
376
+
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
377
+
submissionsQuery := fmt.Sprintf(`
378
+
select
379
+
id, pull_id, round_number
380
+
from
381
+
pull_submissions
382
+
where
383
+
repo_at = ? and pull_id in (%s)
384
+
`, inClause)
385
+
386
+
args := make([]any, len(pulls)+1)
387
+
args[0] = repoAt.String()
388
+
idx := 1
389
+
for _, p := range pulls {
390
+
args[idx] = p.PullId
391
+
idx += 1
392
+
}
393
+
submissionsRows, err := e.Query(submissionsQuery, args...)
394
+
if err != nil {
395
+
return nil, err
396
+
}
397
+
defer submissionsRows.Close()
398
+
399
+
for submissionsRows.Next() {
400
+
var s PullSubmission
401
+
err := submissionsRows.Scan(
402
+
&s.ID,
403
+
&s.PullId,
404
+
&s.RoundNumber,
405
+
)
406
+
if err != nil {
407
+
return nil, err
408
+
}
409
+
410
+
if p, ok := pulls[s.PullId]; ok {
411
+
p.Submissions = make([]*PullSubmission, s.RoundNumber+1)
412
+
p.Submissions[s.RoundNumber] = &s
413
+
}
414
+
}
415
if err := rows.Err(); err != nil {
416
return nil, err
417
}
418
419
+
// get comment count on latest submission on each pull
420
+
inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
421
+
commentsQuery := fmt.Sprintf(`
422
+
select
423
+
count(id), pull_id
424
+
from
425
+
pull_comments
426
+
where
427
+
submission_id in (%s)
428
+
group by
429
+
submission_id
430
+
`, inClause)
431
+
432
+
args = []any{}
433
+
for _, p := range pulls {
434
+
args = append(args, p.Submissions[p.LastRoundNumber()].ID)
435
+
}
436
+
commentsRows, err := e.Query(commentsQuery, args...)
437
+
if err != nil {
438
+
return nil, err
439
+
}
440
+
defer commentsRows.Close()
441
+
442
+
for commentsRows.Next() {
443
+
var commentCount, pullId int
444
+
err := commentsRows.Scan(
445
+
&commentCount,
446
+
&pullId,
447
+
)
448
+
if err != nil {
449
+
return nil, err
450
+
}
451
+
if p, ok := pulls[pullId]; ok {
452
+
p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount)
453
+
}
454
+
}
455
+
if err := rows.Err(); err != nil {
456
+
return nil, err
457
+
}
458
+
459
+
orderedByDate := []*Pull{}
460
+
for _, p := range pulls {
461
+
orderedByDate = append(orderedByDate, p)
462
+
}
463
+
sort.Slice(orderedByDate, func(i, j int) bool {
464
+
return orderedByDate[i].Created.After(orderedByDate[j].Created)
465
+
})
466
+
467
+
return orderedByDate, nil
468
}
469
470
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
···
476
title,
477
state,
478
target_branch,
479
repo_at,
480
body,
481
+
rkey,
482
+
source_branch,
483
+
source_repo_at
484
from
485
pulls
486
where
···
490
491
var pull Pull
492
var createdAt string
493
+
var sourceBranch, sourceRepoAt sql.NullString
494
err := row.Scan(
495
&pull.OwnerDid,
496
&pull.PullId,
···
498
&pull.Title,
499
&pull.State,
500
&pull.TargetBranch,
501
&pull.RepoAt,
502
&pull.Body,
503
&pull.Rkey,
504
+
&sourceBranch,
505
+
&sourceRepoAt,
506
)
507
if err != nil {
508
return nil, err
···
514
}
515
pull.Created = createdTime
516
517
+
// populate source
518
+
if sourceBranch.Valid {
519
+
pull.PullSource = &PullSource{
520
+
Branch: sourceBranch.String,
521
+
}
522
+
if sourceRepoAt.Valid {
523
+
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
524
+
if err != nil {
525
+
return nil, err
526
+
}
527
+
pull.PullSource.RepoAt = &sourceRepoAtParsed
528
+
}
529
+
}
530
+
531
submissionsQuery := `
532
select
533
+
id, pull_id, repo_at, round_number, patch, created, source_rev
534
from
535
pull_submissions
536
where
···
547
for submissionsRows.Next() {
548
var submission PullSubmission
549
var submissionCreatedStr string
550
+
var submissionSourceRev sql.NullString
551
err := submissionsRows.Scan(
552
&submission.ID,
553
&submission.PullId,
···
555
&submission.RoundNumber,
556
&submission.Patch,
557
&submissionCreatedStr,
558
+
&submissionSourceRev,
559
)
560
if err != nil {
561
return nil, err
···
567
}
568
submission.Created = submissionCreatedTime
569
570
+
if submissionSourceRev.Valid {
571
+
submission.SourceRev = submissionSourceRev.String
572
+
}
573
+
574
submissionsMap[submission.ID] = &submission
575
}
576
if err = submissionsRows.Close(); err != nil {
···
641
return nil, err
642
}
643
644
+
var pullSourceRepo *Repo
645
+
if pull.PullSource != nil {
646
+
if pull.PullSource.RepoAt != nil {
647
+
pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String())
648
+
if err != nil {
649
+
log.Printf("failed to get repo by at uri: %v", err)
650
+
} else {
651
+
pull.PullSource.Repo = pullSourceRepo
652
+
}
653
+
}
654
+
}
655
+
656
pull.Submissions = make([]*PullSubmission, len(submissionsMap))
657
for _, submission := range submissionsMap {
658
pull.Submissions[submission.RoundNumber] = submission
···
661
return &pull, nil
662
}
663
664
+
// timeframe here is directly passed into the sql query filter, and any
665
+
// timeframe in the past should be negative; e.g.: "-3 months"
666
+
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) {
667
+
var pulls []Pull
668
+
669
+
rows, err := e.Query(`
670
+
select
671
+
p.owner_did,
672
+
p.repo_at,
673
+
p.pull_id,
674
+
p.created,
675
+
p.title,
676
+
p.state,
677
+
r.did,
678
+
r.name,
679
+
r.knot,
680
+
r.rkey,
681
+
r.created
682
+
from
683
+
pulls p
684
+
join
685
+
repos r on p.repo_at = r.at_uri
686
+
where
687
+
p.owner_did = ? and p.created >= date ('now', ?)
688
+
order by
689
+
p.created desc`, did, timeframe)
690
+
if err != nil {
691
+
return nil, err
692
+
}
693
+
defer rows.Close()
694
+
695
+
for rows.Next() {
696
+
var pull Pull
697
+
var repo Repo
698
+
var pullCreatedAt, repoCreatedAt string
699
+
err := rows.Scan(
700
+
&pull.OwnerDid,
701
+
&pull.RepoAt,
702
+
&pull.PullId,
703
+
&pullCreatedAt,
704
+
&pull.Title,
705
+
&pull.State,
706
+
&repo.Did,
707
+
&repo.Name,
708
+
&repo.Knot,
709
+
&repo.Rkey,
710
+
&repoCreatedAt,
711
+
)
712
+
if err != nil {
713
+
return nil, err
714
+
}
715
+
716
+
pullCreatedTime, err := time.Parse(time.RFC3339, pullCreatedAt)
717
+
if err != nil {
718
+
return nil, err
719
+
}
720
+
pull.Created = pullCreatedTime
721
+
722
+
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
723
+
if err != nil {
724
+
return nil, err
725
+
}
726
+
repo.Created = repoCreatedTime
727
+
728
+
pull.Repo = &repo
729
+
730
+
pulls = append(pulls, pull)
731
+
}
732
+
733
+
if err := rows.Err(); err != nil {
734
+
return nil, err
735
+
}
736
+
737
+
return pulls, nil
738
+
}
739
+
740
func NewPullComment(e Execer, comment *PullComment) (int64, error) {
741
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
742
res, err := e.Exec(
···
780
return err
781
}
782
783
+
func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error {
784
newRoundNumber := len(pull.Submissions)
785
_, err := e.Exec(`
786
+
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
787
+
values (?, ?, ?, ?, ?)
788
+
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
789
790
return err
791
}
+141
-15
appview/db/repos.go
+141
-15
appview/db/repos.go
···
2
3
import (
4
"database/sql"
5
"time"
6
)
7
8
type Repo struct {
···
16
17
// optionally, populate this when querying for reverse mappings
18
RepoStats *RepoStats
19
}
20
21
func GetAllRepos(e Execer, limit int) ([]Repo, error) {
22
var repos []Repo
23
24
rows, err := e.Query(
25
-
`select did, name, knot, rkey, description, created
26
from repos
27
order by created desc
28
limit ?
···
37
for rows.Next() {
38
var repo Repo
39
err := scanRepo(
40
-
rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created,
41
)
42
if err != nil {
43
return nil, err
···
63
r.rkey,
64
r.description,
65
r.created,
66
-
count(s.id) as star_count
67
from
68
repos r
69
left join
···
71
where
72
r.did = ?
73
group by
74
-
r.at_uri`, did)
75
if err != nil {
76
return nil, err
77
}
···
82
var repoStats RepoStats
83
var createdAt string
84
var nullableDescription sql.NullString
85
86
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount)
87
if err != nil {
88
return nil, err
89
}
90
91
if nullableDescription.Valid {
92
repo.Description = nullableDescription.String
93
-
} else {
94
-
repo.Description = ""
95
}
96
97
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
···
159
160
func AddRepo(e Execer, repo *Repo) error {
161
_, err := e.Exec(
162
-
`insert into repos
163
-
(did, name, knot, rkey, at_uri, description)
164
-
values (?, ?, ?, ?, ?, ?)`,
165
-
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description,
166
)
167
return err
168
}
169
170
-
func RemoveRepo(e Execer, did, name, rkey string) error {
171
-
_, err := e.Exec(`delete from repos where did = ? and name = ? and rkey = ?`, did, name, rkey)
172
return err
173
}
174
175
func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error {
176
_, err := e.Exec(
177
`insert into collaborators (did, repo)
···
249
PullCount PullCount
250
}
251
252
-
func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time) error {
253
var createdAt string
254
var nullableDescription sql.NullString
255
-
if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt); err != nil {
256
return err
257
}
258
···
267
*created = time.Now()
268
} else {
269
*created = createdAtTime
270
}
271
272
return nil
···
2
3
import (
4
"database/sql"
5
+
"fmt"
6
"time"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
securejoin "github.com/cyphar/filepath-securejoin"
10
+
"tangled.sh/tangled.sh/core/api/tangled"
11
)
12
13
type Repo struct {
···
21
22
// optionally, populate this when querying for reverse mappings
23
RepoStats *RepoStats
24
+
25
+
// optional
26
+
Source string
27
+
}
28
+
29
+
func (r Repo) RepoAt() syntax.ATURI {
30
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
31
+
}
32
+
33
+
func (r Repo) DidSlashRepo() string {
34
+
p, _ := securejoin.SecureJoin(r.Did, r.Name)
35
+
return p
36
}
37
38
func GetAllRepos(e Execer, limit int) ([]Repo, error) {
39
var repos []Repo
40
41
rows, err := e.Query(
42
+
`select did, name, knot, rkey, description, created, source
43
from repos
44
order by created desc
45
limit ?
···
54
for rows.Next() {
55
var repo Repo
56
err := scanRepo(
57
+
rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source,
58
)
59
if err != nil {
60
return nil, err
···
80
r.rkey,
81
r.description,
82
r.created,
83
+
count(s.id) as star_count,
84
+
r.source
85
from
86
repos r
87
left join
···
89
where
90
r.did = ?
91
group by
92
+
r.at_uri
93
+
order by r.created desc`,
94
+
did)
95
if err != nil {
96
return nil, err
97
}
···
102
var repoStats RepoStats
103
var createdAt string
104
var nullableDescription sql.NullString
105
+
var nullableSource sql.NullString
106
107
+
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource)
108
if err != nil {
109
return nil, err
110
}
111
112
if nullableDescription.Valid {
113
repo.Description = nullableDescription.String
114
+
}
115
+
116
+
if nullableSource.Valid {
117
+
repo.Source = nullableSource.String
118
}
119
120
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
···
182
183
func AddRepo(e Execer, repo *Repo) error {
184
_, err := e.Exec(
185
+
`insert into repos
186
+
(did, name, knot, rkey, at_uri, description, source)
187
+
values (?, ?, ?, ?, ?, ?, ?)`,
188
+
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source,
189
)
190
return err
191
}
192
193
+
func RemoveRepo(e Execer, did, name string) error {
194
+
_, err := e.Exec(`delete from repos where did = ? and name = ?`, did, name)
195
return err
196
}
197
198
+
func GetRepoSource(e Execer, repoAt syntax.ATURI) (string, error) {
199
+
var nullableSource sql.NullString
200
+
err := e.QueryRow(`select source from repos where at_uri = ?`, repoAt).Scan(&nullableSource)
201
+
if err != nil {
202
+
return "", err
203
+
}
204
+
return nullableSource.String, nil
205
+
}
206
+
207
+
func GetForksByDid(e Execer, did string) ([]Repo, error) {
208
+
var repos []Repo
209
+
210
+
rows, err := e.Query(
211
+
`select did, name, knot, rkey, description, created, at_uri, source
212
+
from repos
213
+
where did = ? and source is not null and source != ''
214
+
order by created desc`,
215
+
did,
216
+
)
217
+
if err != nil {
218
+
return nil, err
219
+
}
220
+
defer rows.Close()
221
+
222
+
for rows.Next() {
223
+
var repo Repo
224
+
var createdAt string
225
+
var nullableDescription sql.NullString
226
+
var nullableSource sql.NullString
227
+
228
+
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource)
229
+
if err != nil {
230
+
return nil, err
231
+
}
232
+
233
+
if nullableDescription.Valid {
234
+
repo.Description = nullableDescription.String
235
+
}
236
+
237
+
if nullableSource.Valid {
238
+
repo.Source = nullableSource.String
239
+
}
240
+
241
+
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
242
+
if err != nil {
243
+
repo.Created = time.Now()
244
+
} else {
245
+
repo.Created = createdAtTime
246
+
}
247
+
248
+
repos = append(repos, repo)
249
+
}
250
+
251
+
if err := rows.Err(); err != nil {
252
+
return nil, err
253
+
}
254
+
255
+
return repos, nil
256
+
}
257
+
258
+
func GetForkByDid(e Execer, did string, name string) (*Repo, error) {
259
+
var repo Repo
260
+
var createdAt string
261
+
var nullableDescription sql.NullString
262
+
var nullableSource sql.NullString
263
+
264
+
row := e.QueryRow(
265
+
`select did, name, knot, rkey, description, created, at_uri, source
266
+
from repos
267
+
where did = ? and name = ? and source is not null and source != ''`,
268
+
did, name,
269
+
)
270
+
271
+
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource)
272
+
if err != nil {
273
+
return nil, err
274
+
}
275
+
276
+
if nullableDescription.Valid {
277
+
repo.Description = nullableDescription.String
278
+
}
279
+
280
+
if nullableSource.Valid {
281
+
repo.Source = nullableSource.String
282
+
}
283
+
284
+
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
285
+
if err != nil {
286
+
repo.Created = time.Now()
287
+
} else {
288
+
repo.Created = createdAtTime
289
+
}
290
+
291
+
return &repo, nil
292
+
}
293
+
294
func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error {
295
_, err := e.Exec(
296
`insert into collaborators (did, repo)
···
368
PullCount PullCount
369
}
370
371
+
func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error {
372
var createdAt string
373
var nullableDescription sql.NullString
374
+
var nullableSource sql.NullString
375
+
if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil {
376
return err
377
}
378
···
387
*created = time.Now()
388
} else {
389
*created = createdAtTime
390
+
}
391
+
392
+
if nullableSource.Valid {
393
+
*source = nullableSource.String
394
+
} else {
395
+
*source = ""
396
}
397
398
return nil
+6
appview/db/star.go
+6
appview/db/star.go
···
69
return err
70
}
71
72
+
// Remove a star
73
+
func DeleteStarByRkey(e Execer, starredByDid string, rkey string) error {
74
+
_, err := e.Exec(`delete from stars where starred_by_did = ? and rkey = ?`, starredByDid, rkey)
75
+
return err
76
+
}
77
+
78
func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) {
79
stars := 0
80
err := e.QueryRow(
+13
appview/db/timeline.go
+13
appview/db/timeline.go
···
9
*Repo
10
*Follow
11
*Star
12
+
13
EventAt time.Time
14
+
15
+
// optional: populate only if Repo is a fork
16
+
Source *Repo
17
}
18
19
// TODO: this gathers heterogenous events from different sources and aggregates
···
38
}
39
40
for _, repo := range repos {
41
+
var sourceRepo *Repo
42
+
if repo.Source != "" {
43
+
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
44
+
if err != nil {
45
+
return nil, err
46
+
}
47
+
}
48
+
49
events = append(events, TimelineEvent{
50
Repo: &repo,
51
EventAt: repo.Created,
52
+
Source: sourceRepo,
53
})
54
}
55
+62
appview/filetree/filetree.go
+62
appview/filetree/filetree.go
···
···
1
+
package filetree
2
+
3
+
import (
4
+
"path/filepath"
5
+
"sort"
6
+
"strings"
7
+
)
8
+
9
+
type FileTreeNode struct {
10
+
Name string
11
+
Path string
12
+
IsDirectory bool
13
+
Children map[string]*FileTreeNode
14
+
}
15
+
16
+
// NewNode creates a new node
17
+
func newNode(name, path string, isDir bool) *FileTreeNode {
18
+
return &FileTreeNode{
19
+
Name: name,
20
+
Path: path,
21
+
IsDirectory: isDir,
22
+
Children: make(map[string]*FileTreeNode),
23
+
}
24
+
}
25
+
26
+
func FileTree(files []string) *FileTreeNode {
27
+
rootNode := newNode("", "", true)
28
+
29
+
sort.Strings(files)
30
+
31
+
for _, file := range files {
32
+
if file == "" {
33
+
continue
34
+
}
35
+
36
+
parts := strings.Split(filepath.Clean(file), "/")
37
+
if len(parts) == 0 {
38
+
continue
39
+
}
40
+
41
+
currentNode := rootNode
42
+
currentPath := ""
43
+
44
+
for i, part := range parts {
45
+
if currentPath == "" {
46
+
currentPath = part
47
+
} else {
48
+
currentPath = filepath.Join(currentPath, part)
49
+
}
50
+
51
+
isDir := i < len(parts)-1
52
+
53
+
if _, exists := currentNode.Children[part]; !exists {
54
+
currentNode.Children[part] = newNode(part, currentPath, isDir)
55
+
}
56
+
57
+
currentNode = currentNode.Children[part]
58
+
}
59
+
}
60
+
61
+
return rootNode
62
+
}
+287
appview/ingester.go
+287
appview/ingester.go
···
···
1
+
package appview
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"fmt"
7
+
"log"
8
+
"time"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"github.com/bluesky-social/jetstream/pkg/models"
12
+
"github.com/go-git/go-git/v5/plumbing"
13
+
"github.com/ipfs/go-cid"
14
+
"tangled.sh/tangled.sh/core/api/tangled"
15
+
"tangled.sh/tangled.sh/core/appview/db"
16
+
"tangled.sh/tangled.sh/core/rbac"
17
+
)
18
+
19
+
type Ingester func(ctx context.Context, e *models.Event) error
20
+
21
+
func Ingest(d db.DbWrapper, enforcer *rbac.Enforcer) Ingester {
22
+
return func(ctx context.Context, e *models.Event) error {
23
+
var err error
24
+
defer func() {
25
+
eventTime := e.TimeUS
26
+
lastTimeUs := eventTime + 1
27
+
if err := d.SaveLastTimeUs(lastTimeUs); err != nil {
28
+
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
29
+
}
30
+
}()
31
+
32
+
if e.Kind != models.EventKindCommit {
33
+
return nil
34
+
}
35
+
36
+
switch e.Commit.Collection {
37
+
case tangled.GraphFollowNSID:
38
+
ingestFollow(&d, e)
39
+
case tangled.FeedStarNSID:
40
+
ingestStar(&d, e)
41
+
case tangled.PublicKeyNSID:
42
+
ingestPublicKey(&d, e)
43
+
case tangled.RepoArtifactNSID:
44
+
ingestArtifact(&d, e, enforcer)
45
+
case tangled.ActorProfileNSID:
46
+
ingestProfile(&d, e)
47
+
}
48
+
49
+
return err
50
+
}
51
+
}
52
+
53
+
func ingestStar(d *db.DbWrapper, e *models.Event) error {
54
+
var err error
55
+
did := e.Did
56
+
57
+
switch e.Commit.Operation {
58
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
59
+
var subjectUri syntax.ATURI
60
+
61
+
raw := json.RawMessage(e.Commit.Record)
62
+
record := tangled.FeedStar{}
63
+
err := json.Unmarshal(raw, &record)
64
+
if err != nil {
65
+
log.Println("invalid record")
66
+
return err
67
+
}
68
+
69
+
subjectUri, err = syntax.ParseATURI(record.Subject)
70
+
if err != nil {
71
+
log.Println("invalid record")
72
+
return err
73
+
}
74
+
err = db.AddStar(d, did, subjectUri, e.Commit.RKey)
75
+
case models.CommitOperationDelete:
76
+
err = db.DeleteStarByRkey(d, did, e.Commit.RKey)
77
+
}
78
+
79
+
if err != nil {
80
+
return fmt.Errorf("failed to %s star record: %w", e.Commit.Operation, err)
81
+
}
82
+
83
+
return nil
84
+
}
85
+
86
+
func ingestFollow(d *db.DbWrapper, e *models.Event) error {
87
+
var err error
88
+
did := e.Did
89
+
90
+
switch e.Commit.Operation {
91
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
92
+
raw := json.RawMessage(e.Commit.Record)
93
+
record := tangled.GraphFollow{}
94
+
err = json.Unmarshal(raw, &record)
95
+
if err != nil {
96
+
log.Println("invalid record")
97
+
return err
98
+
}
99
+
100
+
subjectDid := record.Subject
101
+
err = db.AddFollow(d, did, subjectDid, e.Commit.RKey)
102
+
case models.CommitOperationDelete:
103
+
err = db.DeleteFollowByRkey(d, did, e.Commit.RKey)
104
+
}
105
+
106
+
if err != nil {
107
+
return fmt.Errorf("failed to %s follow record: %w", e.Commit.Operation, err)
108
+
}
109
+
110
+
return nil
111
+
}
112
+
113
+
func ingestPublicKey(d *db.DbWrapper, e *models.Event) error {
114
+
did := e.Did
115
+
var err error
116
+
117
+
switch e.Commit.Operation {
118
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
119
+
log.Println("processing add of pubkey")
120
+
raw := json.RawMessage(e.Commit.Record)
121
+
record := tangled.PublicKey{}
122
+
err = json.Unmarshal(raw, &record)
123
+
if err != nil {
124
+
log.Printf("invalid record: %s", err)
125
+
return err
126
+
}
127
+
128
+
name := record.Name
129
+
key := record.Key
130
+
err = db.AddPublicKey(d, did, name, key, e.Commit.RKey)
131
+
case models.CommitOperationDelete:
132
+
log.Println("processing delete of pubkey")
133
+
err = db.DeletePublicKeyByRkey(d, did, e.Commit.RKey)
134
+
}
135
+
136
+
if err != nil {
137
+
return fmt.Errorf("failed to %s pubkey record: %w", e.Commit.Operation, err)
138
+
}
139
+
140
+
return nil
141
+
}
142
+
143
+
func ingestArtifact(d *db.DbWrapper, e *models.Event, enforcer *rbac.Enforcer) error {
144
+
did := e.Did
145
+
var err error
146
+
147
+
switch e.Commit.Operation {
148
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
149
+
raw := json.RawMessage(e.Commit.Record)
150
+
record := tangled.RepoArtifact{}
151
+
err = json.Unmarshal(raw, &record)
152
+
if err != nil {
153
+
log.Printf("invalid record: %s", err)
154
+
return err
155
+
}
156
+
157
+
repoAt, err := syntax.ParseATURI(record.Repo)
158
+
if err != nil {
159
+
return err
160
+
}
161
+
162
+
repo, err := db.GetRepoByAtUri(d, repoAt.String())
163
+
if err != nil {
164
+
return err
165
+
}
166
+
167
+
ok, err := enforcer.E.Enforce(did, repo.Knot, repo.DidSlashRepo(), "repo:push")
168
+
if err != nil || !ok {
169
+
return err
170
+
}
171
+
172
+
createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
173
+
if err != nil {
174
+
createdAt = time.Now()
175
+
}
176
+
177
+
artifact := db.Artifact{
178
+
Did: did,
179
+
Rkey: e.Commit.RKey,
180
+
RepoAt: repoAt,
181
+
Tag: plumbing.Hash(record.Tag),
182
+
CreatedAt: createdAt,
183
+
BlobCid: cid.Cid(record.Artifact.Ref),
184
+
Name: record.Name,
185
+
Size: uint64(record.Artifact.Size),
186
+
MimeType: record.Artifact.MimeType,
187
+
}
188
+
189
+
err = db.AddArtifact(d, artifact)
190
+
case models.CommitOperationDelete:
191
+
err = db.DeleteArtifact(d, db.Filter("did", did), db.Filter("rkey", e.Commit.RKey))
192
+
}
193
+
194
+
if err != nil {
195
+
return fmt.Errorf("failed to %s artifact record: %w", e.Commit.Operation, err)
196
+
}
197
+
198
+
return nil
199
+
}
200
+
201
+
func ingestProfile(d *db.DbWrapper, e *models.Event) error {
202
+
did := e.Did
203
+
var err error
204
+
205
+
if e.Commit.RKey != "self" {
206
+
return fmt.Errorf("ingestProfile only ingests `self` record")
207
+
}
208
+
209
+
switch e.Commit.Operation {
210
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
211
+
raw := json.RawMessage(e.Commit.Record)
212
+
record := tangled.ActorProfile{}
213
+
err = json.Unmarshal(raw, &record)
214
+
if err != nil {
215
+
log.Printf("invalid record: %s", err)
216
+
return err
217
+
}
218
+
219
+
description := ""
220
+
if record.Description != nil {
221
+
description = *record.Description
222
+
}
223
+
224
+
includeBluesky := record.Bluesky
225
+
226
+
location := ""
227
+
if record.Location != nil {
228
+
location = *record.Location
229
+
}
230
+
231
+
var links [5]string
232
+
for i, l := range record.Links {
233
+
if i < 5 {
234
+
links[i] = l
235
+
}
236
+
}
237
+
238
+
var stats [2]db.VanityStat
239
+
for i, s := range record.Stats {
240
+
if i < 2 {
241
+
stats[i].Kind = db.VanityStatKind(s)
242
+
}
243
+
}
244
+
245
+
var pinned [6]syntax.ATURI
246
+
for i, r := range record.PinnedRepositories {
247
+
if i < 6 {
248
+
pinned[i] = syntax.ATURI(r)
249
+
}
250
+
}
251
+
252
+
profile := db.Profile{
253
+
Did: did,
254
+
Description: description,
255
+
IncludeBluesky: includeBluesky,
256
+
Location: location,
257
+
Links: links,
258
+
Stats: stats,
259
+
PinnedRepos: pinned,
260
+
}
261
+
262
+
ddb, ok := d.Execer.(*db.DB)
263
+
if !ok {
264
+
return fmt.Errorf("failed to index profile record, invalid db cast")
265
+
}
266
+
267
+
tx, err := ddb.Begin()
268
+
if err != nil {
269
+
return fmt.Errorf("failed to start transaction")
270
+
}
271
+
272
+
err = db.ValidateProfile(tx, &profile)
273
+
if err != nil {
274
+
return fmt.Errorf("invalid profile record")
275
+
}
276
+
277
+
err = db.UpsertProfile(tx, &profile)
278
+
case models.CommitOperationDelete:
279
+
err = db.DeleteArtifact(d, db.Filter("did", did), db.Filter("rkey", e.Commit.RKey))
280
+
}
281
+
282
+
if err != nil {
283
+
return fmt.Errorf("failed to %s profile record: %w", e.Commit.Operation, err)
284
+
}
285
+
286
+
return nil
287
+
}
+489
appview/knotclient/signer.go
+489
appview/knotclient/signer.go
···
···
1
+
package knotclient
2
+
3
+
import (
4
+
"bytes"
5
+
"crypto/hmac"
6
+
"crypto/sha256"
7
+
"encoding/hex"
8
+
"encoding/json"
9
+
"fmt"
10
+
"io"
11
+
"log"
12
+
"net/http"
13
+
"net/url"
14
+
"strconv"
15
+
"time"
16
+
17
+
"tangled.sh/tangled.sh/core/types"
18
+
)
19
+
20
+
type SignerTransport struct {
21
+
Secret string
22
+
}
23
+
24
+
func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
25
+
timestamp := time.Now().Format(time.RFC3339)
26
+
mac := hmac.New(sha256.New, []byte(s.Secret))
27
+
message := req.Method + req.URL.Path + timestamp
28
+
mac.Write([]byte(message))
29
+
signature := hex.EncodeToString(mac.Sum(nil))
30
+
req.Header.Set("X-Signature", signature)
31
+
req.Header.Set("X-Timestamp", timestamp)
32
+
return http.DefaultTransport.RoundTrip(req)
33
+
}
34
+
35
+
type SignedClient struct {
36
+
Secret string
37
+
Url *url.URL
38
+
client *http.Client
39
+
}
40
+
41
+
func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) {
42
+
client := &http.Client{
43
+
Timeout: 5 * time.Second,
44
+
Transport: SignerTransport{
45
+
Secret: secret,
46
+
},
47
+
}
48
+
49
+
scheme := "https"
50
+
if dev {
51
+
scheme = "http"
52
+
}
53
+
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
54
+
if err != nil {
55
+
return nil, err
56
+
}
57
+
58
+
signedClient := &SignedClient{
59
+
Secret: secret,
60
+
client: client,
61
+
Url: url,
62
+
}
63
+
64
+
return signedClient, nil
65
+
}
66
+
67
+
func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) {
68
+
return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body))
69
+
}
70
+
71
+
func (s *SignedClient) Init(did string) (*http.Response, error) {
72
+
const (
73
+
Method = "POST"
74
+
Endpoint = "/init"
75
+
)
76
+
77
+
body, _ := json.Marshal(map[string]any{
78
+
"did": did,
79
+
})
80
+
81
+
req, err := s.newRequest(Method, Endpoint, body)
82
+
if err != nil {
83
+
return nil, err
84
+
}
85
+
86
+
return s.client.Do(req)
87
+
}
88
+
89
+
func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) {
90
+
const (
91
+
Method = "PUT"
92
+
Endpoint = "/repo/new"
93
+
)
94
+
95
+
body, _ := json.Marshal(map[string]any{
96
+
"did": did,
97
+
"name": repoName,
98
+
"default_branch": defaultBranch,
99
+
})
100
+
101
+
req, err := s.newRequest(Method, Endpoint, body)
102
+
if err != nil {
103
+
return nil, err
104
+
}
105
+
106
+
return s.client.Do(req)
107
+
}
108
+
109
+
func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) {
110
+
const (
111
+
Method = "POST"
112
+
Endpoint = "/repo/fork"
113
+
)
114
+
115
+
body, _ := json.Marshal(map[string]any{
116
+
"did": ownerDid,
117
+
"source": source,
118
+
"name": name,
119
+
})
120
+
121
+
req, err := s.newRequest(Method, Endpoint, body)
122
+
if err != nil {
123
+
return nil, err
124
+
}
125
+
126
+
return s.client.Do(req)
127
+
}
128
+
129
+
func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) {
130
+
const (
131
+
Method = "DELETE"
132
+
Endpoint = "/repo"
133
+
)
134
+
135
+
body, _ := json.Marshal(map[string]any{
136
+
"did": did,
137
+
"name": repoName,
138
+
})
139
+
140
+
req, err := s.newRequest(Method, Endpoint, body)
141
+
if err != nil {
142
+
return nil, err
143
+
}
144
+
145
+
return s.client.Do(req)
146
+
}
147
+
148
+
func (s *SignedClient) AddMember(did string) (*http.Response, error) {
149
+
const (
150
+
Method = "PUT"
151
+
Endpoint = "/member/add"
152
+
)
153
+
154
+
body, _ := json.Marshal(map[string]any{
155
+
"did": did,
156
+
})
157
+
158
+
req, err := s.newRequest(Method, Endpoint, body)
159
+
if err != nil {
160
+
return nil, err
161
+
}
162
+
163
+
return s.client.Do(req)
164
+
}
165
+
166
+
func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) {
167
+
const (
168
+
Method = "PUT"
169
+
)
170
+
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
171
+
172
+
body, _ := json.Marshal(map[string]any{
173
+
"branch": branch,
174
+
})
175
+
176
+
req, err := s.newRequest(Method, endpoint, body)
177
+
if err != nil {
178
+
return nil, err
179
+
}
180
+
181
+
return s.client.Do(req)
182
+
}
183
+
184
+
func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) {
185
+
const (
186
+
Method = "POST"
187
+
)
188
+
endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName)
189
+
190
+
body, _ := json.Marshal(map[string]any{
191
+
"did": memberDid,
192
+
})
193
+
194
+
req, err := s.newRequest(Method, endpoint, body)
195
+
if err != nil {
196
+
return nil, err
197
+
}
198
+
199
+
return s.client.Do(req)
200
+
}
201
+
202
+
func (s *SignedClient) Merge(
203
+
patch []byte,
204
+
ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string,
205
+
) (*http.Response, error) {
206
+
const (
207
+
Method = "POST"
208
+
)
209
+
endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo)
210
+
211
+
mr := types.MergeRequest{
212
+
Branch: branch,
213
+
CommitMessage: commitMessage,
214
+
CommitBody: commitBody,
215
+
AuthorName: authorName,
216
+
AuthorEmail: authorEmail,
217
+
Patch: string(patch),
218
+
}
219
+
220
+
body, _ := json.Marshal(mr)
221
+
222
+
req, err := s.newRequest(Method, endpoint, body)
223
+
if err != nil {
224
+
return nil, err
225
+
}
226
+
227
+
return s.client.Do(req)
228
+
}
229
+
230
+
func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) {
231
+
const (
232
+
Method = "POST"
233
+
)
234
+
endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo)
235
+
236
+
body, _ := json.Marshal(map[string]any{
237
+
"patch": string(patch),
238
+
"branch": branch,
239
+
})
240
+
241
+
req, err := s.newRequest(Method, endpoint, body)
242
+
if err != nil {
243
+
return nil, err
244
+
}
245
+
246
+
return s.client.Do(req)
247
+
}
248
+
249
+
func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) {
250
+
const (
251
+
Method = "POST"
252
+
)
253
+
endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch))
254
+
255
+
req, err := s.newRequest(Method, endpoint, nil)
256
+
if err != nil {
257
+
return nil, err
258
+
}
259
+
260
+
return s.client.Do(req)
261
+
}
262
+
263
+
type UnsignedClient struct {
264
+
Url *url.URL
265
+
client *http.Client
266
+
}
267
+
268
+
func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) {
269
+
client := &http.Client{
270
+
Timeout: 5 * time.Second,
271
+
}
272
+
273
+
scheme := "https"
274
+
if dev {
275
+
scheme = "http"
276
+
}
277
+
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
278
+
if err != nil {
279
+
return nil, err
280
+
}
281
+
282
+
unsignedClient := &UnsignedClient{
283
+
client: client,
284
+
Url: url,
285
+
}
286
+
287
+
return unsignedClient, nil
288
+
}
289
+
290
+
func (us *UnsignedClient) newRequest(method, endpoint string, query url.Values, body []byte) (*http.Request, error) {
291
+
reqUrl := us.Url.JoinPath(endpoint)
292
+
293
+
// add query parameters
294
+
if query != nil {
295
+
reqUrl.RawQuery = query.Encode()
296
+
}
297
+
298
+
return http.NewRequest(method, reqUrl.String(), bytes.NewReader(body))
299
+
}
300
+
301
+
func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*http.Response, error) {
302
+
const (
303
+
Method = "GET"
304
+
)
305
+
306
+
endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref)
307
+
if ref == "" {
308
+
endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName)
309
+
}
310
+
311
+
req, err := us.newRequest(Method, endpoint, nil, nil)
312
+
if err != nil {
313
+
return nil, err
314
+
}
315
+
316
+
return us.client.Do(req)
317
+
}
318
+
319
+
func (us *UnsignedClient) Log(ownerDid, repoName, ref string, page int) (*http.Response, error) {
320
+
const (
321
+
Method = "GET"
322
+
)
323
+
324
+
endpoint := fmt.Sprintf("/%s/%s/log/%s", ownerDid, repoName, url.PathEscape(ref))
325
+
326
+
query := url.Values{}
327
+
query.Add("page", strconv.Itoa(page))
328
+
query.Add("per_page", strconv.Itoa(60))
329
+
330
+
req, err := us.newRequest(Method, endpoint, query, nil)
331
+
if err != nil {
332
+
return nil, err
333
+
}
334
+
335
+
return us.client.Do(req)
336
+
}
337
+
338
+
func (us *UnsignedClient) Branches(ownerDid, repoName string) (*http.Response, error) {
339
+
const (
340
+
Method = "GET"
341
+
)
342
+
343
+
endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName)
344
+
345
+
req, err := us.newRequest(Method, endpoint, nil, nil)
346
+
if err != nil {
347
+
return nil, err
348
+
}
349
+
350
+
return us.client.Do(req)
351
+
}
352
+
353
+
func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) {
354
+
const (
355
+
Method = "GET"
356
+
)
357
+
358
+
endpoint := fmt.Sprintf("/%s/%s/tags", ownerDid, repoName)
359
+
360
+
req, err := us.newRequest(Method, endpoint, nil, nil)
361
+
if err != nil {
362
+
return nil, err
363
+
}
364
+
365
+
resp, err := us.client.Do(req)
366
+
if err != nil {
367
+
return nil, err
368
+
}
369
+
370
+
body, err := io.ReadAll(resp.Body)
371
+
if err != nil {
372
+
return nil, err
373
+
}
374
+
375
+
var result types.RepoTagsResponse
376
+
err = json.Unmarshal(body, &result)
377
+
if err != nil {
378
+
return nil, err
379
+
}
380
+
381
+
return &result, nil
382
+
}
383
+
384
+
func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*http.Response, error) {
385
+
const (
386
+
Method = "GET"
387
+
)
388
+
389
+
endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, url.PathEscape(branch))
390
+
391
+
req, err := us.newRequest(Method, endpoint, nil, nil)
392
+
if err != nil {
393
+
return nil, err
394
+
}
395
+
396
+
return us.client.Do(req)
397
+
}
398
+
399
+
func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*types.RepoDefaultBranchResponse, error) {
400
+
const (
401
+
Method = "GET"
402
+
)
403
+
404
+
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
405
+
406
+
req, err := us.newRequest(Method, endpoint, nil, nil)
407
+
if err != nil {
408
+
return nil, err
409
+
}
410
+
411
+
resp, err := us.client.Do(req)
412
+
if err != nil {
413
+
return nil, err
414
+
}
415
+
defer resp.Body.Close()
416
+
417
+
var defaultBranch types.RepoDefaultBranchResponse
418
+
if err := json.NewDecoder(resp.Body).Decode(&defaultBranch); err != nil {
419
+
return nil, err
420
+
}
421
+
422
+
return &defaultBranch, nil
423
+
}
424
+
425
+
func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) {
426
+
const (
427
+
Method = "GET"
428
+
Endpoint = "/capabilities"
429
+
)
430
+
431
+
req, err := us.newRequest(Method, Endpoint, nil, nil)
432
+
if err != nil {
433
+
return nil, err
434
+
}
435
+
436
+
resp, err := us.client.Do(req)
437
+
if err != nil {
438
+
return nil, err
439
+
}
440
+
defer resp.Body.Close()
441
+
442
+
var capabilities types.Capabilities
443
+
if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil {
444
+
return nil, err
445
+
}
446
+
447
+
return &capabilities, nil
448
+
}
449
+
450
+
func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) {
451
+
const (
452
+
Method = "GET"
453
+
)
454
+
455
+
endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2))
456
+
457
+
req, err := us.newRequest(Method, endpoint, nil, nil)
458
+
if err != nil {
459
+
return nil, fmt.Errorf("Failed to create request.")
460
+
}
461
+
462
+
compareResp, err := us.client.Do(req)
463
+
if err != nil {
464
+
return nil, fmt.Errorf("Failed to create request.")
465
+
}
466
+
defer compareResp.Body.Close()
467
+
468
+
switch compareResp.StatusCode {
469
+
case 404:
470
+
case 400:
471
+
return nil, fmt.Errorf("Branch comparisons not supported on this knot.")
472
+
}
473
+
474
+
respBody, err := io.ReadAll(compareResp.Body)
475
+
if err != nil {
476
+
log.Println("failed to compare across branches")
477
+
return nil, fmt.Errorf("Failed to compare branches.")
478
+
}
479
+
defer compareResp.Body.Close()
480
+
481
+
var formatPatchResponse types.RepoFormatPatchResponse
482
+
err = json.Unmarshal(respBody, &formatPatchResponse)
483
+
if err != nil {
484
+
log.Println("failed to unmarshal format-patch response", err)
485
+
return nil, fmt.Errorf("failed to compare branches.")
486
+
}
487
+
488
+
return &formatPatchResponse, nil
489
+
}
+73
appview/middleware/middleware.go
+73
appview/middleware/middleware.go
···
···
1
+
package middleware
2
+
3
+
import (
4
+
"context"
5
+
"log"
6
+
"net/http"
7
+
"strconv"
8
+
9
+
"tangled.sh/tangled.sh/core/appview/oauth"
10
+
"tangled.sh/tangled.sh/core/appview/pagination"
11
+
)
12
+
13
+
type Middleware func(http.Handler) http.Handler
14
+
15
+
func AuthMiddleware(a *oauth.OAuth) Middleware {
16
+
return func(next http.Handler) http.Handler {
17
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18
+
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
19
+
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
20
+
}
21
+
if r.Header.Get("HX-Request") == "true" {
22
+
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
23
+
w.Header().Set("HX-Redirect", "/login")
24
+
w.WriteHeader(http.StatusOK)
25
+
}
26
+
}
27
+
28
+
_, auth, err := a.GetSession(r)
29
+
if err != nil {
30
+
log.Printf("not logged in, redirecting")
31
+
redirectFunc(w, r)
32
+
return
33
+
}
34
+
35
+
if !auth {
36
+
log.Printf("not logged in, redirecting")
37
+
redirectFunc(w, r)
38
+
return
39
+
}
40
+
41
+
next.ServeHTTP(w, r)
42
+
})
43
+
}
44
+
}
45
+
46
+
func Paginate(next http.Handler) http.Handler {
47
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48
+
page := pagination.FirstPage()
49
+
50
+
offsetVal := r.URL.Query().Get("offset")
51
+
if offsetVal != "" {
52
+
offset, err := strconv.Atoi(offsetVal)
53
+
if err != nil {
54
+
log.Println("invalid offset")
55
+
} else {
56
+
page.Offset = offset
57
+
}
58
+
}
59
+
60
+
limitVal := r.URL.Query().Get("limit")
61
+
if limitVal != "" {
62
+
limit, err := strconv.Atoi(limitVal)
63
+
if err != nil {
64
+
log.Println("invalid limit")
65
+
} else {
66
+
page.Limit = limit
67
+
}
68
+
}
69
+
70
+
ctx := context.WithValue(r.Context(), "page", page)
71
+
next.ServeHTTP(w, r.WithContext(ctx))
72
+
})
73
+
}
+24
appview/oauth/client/oauth_client.go
+24
appview/oauth/client/oauth_client.go
···
···
1
+
package client
2
+
3
+
import (
4
+
oauth "github.com/haileyok/atproto-oauth-golang"
5
+
"github.com/haileyok/atproto-oauth-golang/helpers"
6
+
)
7
+
8
+
type OAuthClient struct {
9
+
*oauth.Client
10
+
}
11
+
12
+
func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) {
13
+
k, err := helpers.ParseJWKFromBytes([]byte(clientJwk))
14
+
if err != nil {
15
+
return nil, err
16
+
}
17
+
18
+
cli, err := oauth.NewClient(oauth.ClientArgs{
19
+
ClientId: clientId,
20
+
ClientJwk: k,
21
+
RedirectUri: redirectUri,
22
+
})
23
+
return &OAuthClient{cli}, err
24
+
}
+309
appview/oauth/handler/handler.go
+309
appview/oauth/handler/handler.go
···
···
1
+
package oauth
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"log"
7
+
"net/http"
8
+
"net/url"
9
+
"strings"
10
+
11
+
"github.com/go-chi/chi/v5"
12
+
"github.com/gorilla/sessions"
13
+
"github.com/haileyok/atproto-oauth-golang/helpers"
14
+
"github.com/lestrrat-go/jwx/v2/jwk"
15
+
"tangled.sh/tangled.sh/core/appview"
16
+
"tangled.sh/tangled.sh/core/appview/db"
17
+
"tangled.sh/tangled.sh/core/appview/knotclient"
18
+
"tangled.sh/tangled.sh/core/appview/middleware"
19
+
"tangled.sh/tangled.sh/core/appview/oauth"
20
+
"tangled.sh/tangled.sh/core/appview/oauth/client"
21
+
"tangled.sh/tangled.sh/core/appview/pages"
22
+
"tangled.sh/tangled.sh/core/rbac"
23
+
)
24
+
25
+
const (
26
+
oauthScope = "atproto transition:generic"
27
+
)
28
+
29
+
type OAuthHandler struct {
30
+
Config *appview.Config
31
+
Pages *pages.Pages
32
+
Resolver *appview.Resolver
33
+
Db *db.DB
34
+
Store *sessions.CookieStore
35
+
OAuth *oauth.OAuth
36
+
Enforcer *rbac.Enforcer
37
+
}
38
+
39
+
func (o *OAuthHandler) Router() http.Handler {
40
+
r := chi.NewRouter()
41
+
42
+
r.Get("/login", o.login)
43
+
r.Post("/login", o.login)
44
+
45
+
r.With(middleware.AuthMiddleware(o.OAuth)).Post("/logout", o.logout)
46
+
47
+
r.Get("/oauth/client-metadata.json", o.clientMetadata)
48
+
r.Get("/oauth/jwks.json", o.jwks)
49
+
r.Get("/oauth/callback", o.callback)
50
+
return r
51
+
}
52
+
53
+
func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) {
54
+
w.Header().Set("Content-Type", "application/json")
55
+
w.WriteHeader(http.StatusOK)
56
+
json.NewEncoder(w).Encode(o.OAuth.ClientMetadata())
57
+
}
58
+
59
+
func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) {
60
+
jwks := o.Config.OAuth.Jwks
61
+
pubKey, err := pubKeyFromJwk(jwks)
62
+
if err != nil {
63
+
log.Printf("error parsing public key: %v", err)
64
+
http.Error(w, err.Error(), http.StatusInternalServerError)
65
+
return
66
+
}
67
+
68
+
response := helpers.CreateJwksResponseObject(pubKey)
69
+
70
+
w.Header().Set("Content-Type", "application/json")
71
+
w.WriteHeader(http.StatusOK)
72
+
json.NewEncoder(w).Encode(response)
73
+
}
74
+
75
+
func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) {
76
+
switch r.Method {
77
+
case http.MethodGet:
78
+
o.Pages.Login(w, pages.LoginParams{})
79
+
case http.MethodPost:
80
+
handle := strings.TrimPrefix(r.FormValue("handle"), "@")
81
+
82
+
resolved, err := o.Resolver.ResolveIdent(r.Context(), handle)
83
+
if err != nil {
84
+
log.Println("failed to resolve handle:", err)
85
+
o.Pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle))
86
+
return
87
+
}
88
+
self := o.OAuth.ClientMetadata()
89
+
oauthClient, err := client.NewClient(
90
+
self.ClientID,
91
+
o.Config.OAuth.Jwks,
92
+
self.RedirectURIs[0],
93
+
)
94
+
95
+
if err != nil {
96
+
log.Println("failed to create oauth client:", err)
97
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
98
+
return
99
+
}
100
+
101
+
authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint())
102
+
if err != nil {
103
+
log.Println("failed to resolve auth server:", err)
104
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
105
+
return
106
+
}
107
+
108
+
authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer)
109
+
if err != nil {
110
+
log.Println("failed to fetch auth server metadata:", err)
111
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
112
+
return
113
+
}
114
+
115
+
dpopKey, err := helpers.GenerateKey(nil)
116
+
if err != nil {
117
+
log.Println("failed to generate dpop key:", err)
118
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
119
+
return
120
+
}
121
+
122
+
dpopKeyJson, err := json.Marshal(dpopKey)
123
+
if err != nil {
124
+
log.Println("failed to marshal dpop key:", err)
125
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
126
+
return
127
+
}
128
+
129
+
parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey)
130
+
if err != nil {
131
+
log.Println("failed to send par auth request:", err)
132
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
133
+
return
134
+
}
135
+
136
+
err = db.SaveOAuthRequest(o.Db, db.OAuthRequest{
137
+
Did: resolved.DID.String(),
138
+
PdsUrl: resolved.PDSEndpoint(),
139
+
Handle: handle,
140
+
AuthserverIss: authMeta.Issuer,
141
+
PkceVerifier: parResp.PkceVerifier,
142
+
DpopAuthserverNonce: parResp.DpopAuthserverNonce,
143
+
DpopPrivateJwk: string(dpopKeyJson),
144
+
State: parResp.State,
145
+
})
146
+
if err != nil {
147
+
log.Println("failed to save oauth request:", err)
148
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
149
+
return
150
+
}
151
+
152
+
u, _ := url.Parse(authMeta.AuthorizationEndpoint)
153
+
query := url.Values{}
154
+
query.Add("client_id", self.ClientID)
155
+
query.Add("request_uri", parResp.RequestUri)
156
+
u.RawQuery = query.Encode()
157
+
o.Pages.HxRedirect(w, u.String())
158
+
}
159
+
}
160
+
161
+
func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) {
162
+
state := r.FormValue("state")
163
+
164
+
oauthRequest, err := db.GetOAuthRequestByState(o.Db, state)
165
+
if err != nil {
166
+
log.Println("failed to get oauth request:", err)
167
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
168
+
return
169
+
}
170
+
171
+
defer func() {
172
+
err := db.DeleteOAuthRequestByState(o.Db, state)
173
+
if err != nil {
174
+
log.Println("failed to delete oauth request for state:", state, err)
175
+
}
176
+
}()
177
+
178
+
error := r.FormValue("error")
179
+
errorDescription := r.FormValue("error_description")
180
+
if error != "" || errorDescription != "" {
181
+
log.Printf("error: %s, %s", error, errorDescription)
182
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
183
+
return
184
+
}
185
+
186
+
code := r.FormValue("code")
187
+
if code == "" {
188
+
log.Println("missing code for state: ", state)
189
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
190
+
return
191
+
}
192
+
193
+
iss := r.FormValue("iss")
194
+
if iss == "" {
195
+
log.Println("missing iss for state: ", state)
196
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
197
+
return
198
+
}
199
+
200
+
self := o.OAuth.ClientMetadata()
201
+
202
+
oauthClient, err := client.NewClient(
203
+
self.ClientID,
204
+
o.Config.OAuth.Jwks,
205
+
self.RedirectURIs[0],
206
+
)
207
+
208
+
if err != nil {
209
+
log.Println("failed to create oauth client:", err)
210
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
211
+
return
212
+
}
213
+
214
+
jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk))
215
+
if err != nil {
216
+
log.Println("failed to parse jwk:", err)
217
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
218
+
return
219
+
}
220
+
221
+
tokenResp, err := oauthClient.InitialTokenRequest(
222
+
r.Context(),
223
+
code,
224
+
oauthRequest.AuthserverIss,
225
+
oauthRequest.PkceVerifier,
226
+
oauthRequest.DpopAuthserverNonce,
227
+
jwk,
228
+
)
229
+
if err != nil {
230
+
log.Println("failed to get token:", err)
231
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
232
+
return
233
+
}
234
+
235
+
if tokenResp.Scope != oauthScope {
236
+
log.Println("scope doesn't match:", tokenResp.Scope)
237
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
238
+
return
239
+
}
240
+
241
+
err = o.OAuth.SaveSession(w, r, oauthRequest, tokenResp)
242
+
if err != nil {
243
+
log.Println("failed to save session:", err)
244
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
245
+
return
246
+
}
247
+
248
+
log.Println("session saved successfully")
249
+
go o.addToDefaultKnot(oauthRequest.Did)
250
+
251
+
http.Redirect(w, r, "/", http.StatusFound)
252
+
}
253
+
254
+
func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) {
255
+
err := o.OAuth.ClearSession(r, w)
256
+
if err != nil {
257
+
log.Println("failed to clear session:", err)
258
+
http.Redirect(w, r, "/", http.StatusFound)
259
+
return
260
+
}
261
+
262
+
log.Println("session cleared successfully")
263
+
http.Redirect(w, r, "/", http.StatusFound)
264
+
}
265
+
266
+
func pubKeyFromJwk(jwks string) (jwk.Key, error) {
267
+
k, err := helpers.ParseJWKFromBytes([]byte(jwks))
268
+
if err != nil {
269
+
return nil, err
270
+
}
271
+
pubKey, err := k.PublicKey()
272
+
if err != nil {
273
+
return nil, err
274
+
}
275
+
return pubKey, nil
276
+
}
277
+
278
+
func (o *OAuthHandler) addToDefaultKnot(did string) {
279
+
defaultKnot := "knot1.tangled.sh"
280
+
281
+
log.Printf("adding %s to default knot", did)
282
+
err := o.Enforcer.AddMember(defaultKnot, did)
283
+
if err != nil {
284
+
log.Println("failed to add user to knot1.tangled.sh: ", err)
285
+
return
286
+
}
287
+
err = o.Enforcer.E.SavePolicy()
288
+
if err != nil {
289
+
log.Println("failed to add user to knot1.tangled.sh: ", err)
290
+
return
291
+
}
292
+
293
+
secret, err := db.GetRegistrationKey(o.Db, defaultKnot)
294
+
if err != nil {
295
+
log.Println("failed to get registration key for knot1.tangled.sh")
296
+
return
297
+
}
298
+
signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.Config.Core.Dev)
299
+
resp, err := signedClient.AddMember(did)
300
+
if err != nil {
301
+
log.Println("failed to add user to knot1.tangled.sh: ", err)
302
+
return
303
+
}
304
+
305
+
if resp.StatusCode != http.StatusNoContent {
306
+
log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode)
307
+
return
308
+
}
309
+
}
+268
appview/oauth/oauth.go
+268
appview/oauth/oauth.go
···
···
1
+
package oauth
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"net/http"
7
+
"net/url"
8
+
"time"
9
+
10
+
"github.com/gorilla/sessions"
11
+
oauth "github.com/haileyok/atproto-oauth-golang"
12
+
"github.com/haileyok/atproto-oauth-golang/helpers"
13
+
"tangled.sh/tangled.sh/core/appview"
14
+
"tangled.sh/tangled.sh/core/appview/db"
15
+
"tangled.sh/tangled.sh/core/appview/oauth/client"
16
+
xrpc "tangled.sh/tangled.sh/core/appview/xrpcclient"
17
+
)
18
+
19
+
type OAuthRequest struct {
20
+
ID uint
21
+
AuthserverIss string
22
+
State string
23
+
Did string
24
+
PdsUrl string
25
+
PkceVerifier string
26
+
DpopAuthserverNonce string
27
+
DpopPrivateJwk string
28
+
}
29
+
30
+
type OAuth struct {
31
+
Store *sessions.CookieStore
32
+
Db *db.DB
33
+
Config *appview.Config
34
+
}
35
+
36
+
func NewOAuth(db *db.DB, config *appview.Config) *OAuth {
37
+
return &OAuth{
38
+
Store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)),
39
+
Db: db,
40
+
Config: config,
41
+
}
42
+
}
43
+
44
+
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq db.OAuthRequest, oresp *oauth.TokenResponse) error {
45
+
// first we save the did in the user session
46
+
userSession, err := o.Store.Get(r, appview.SessionName)
47
+
if err != nil {
48
+
return err
49
+
}
50
+
51
+
userSession.Values[appview.SessionDid] = oreq.Did
52
+
userSession.Values[appview.SessionHandle] = oreq.Handle
53
+
userSession.Values[appview.SessionPds] = oreq.PdsUrl
54
+
userSession.Values[appview.SessionAuthenticated] = true
55
+
err = userSession.Save(r, w)
56
+
if err != nil {
57
+
return fmt.Errorf("error saving user session: %w", err)
58
+
}
59
+
60
+
// then save the whole thing in the db
61
+
session := db.OAuthSession{
62
+
Did: oreq.Did,
63
+
Handle: oreq.Handle,
64
+
PdsUrl: oreq.PdsUrl,
65
+
DpopAuthserverNonce: oreq.DpopAuthserverNonce,
66
+
AuthServerIss: oreq.AuthserverIss,
67
+
DpopPrivateJwk: oreq.DpopPrivateJwk,
68
+
AccessJwt: oresp.AccessToken,
69
+
RefreshJwt: oresp.RefreshToken,
70
+
Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339),
71
+
}
72
+
73
+
return db.SaveOAuthSession(o.Db, session)
74
+
}
75
+
76
+
func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error {
77
+
userSession, err := o.Store.Get(r, appview.SessionName)
78
+
if err != nil || userSession.IsNew {
79
+
return fmt.Errorf("error getting user session (or new session?): %w", err)
80
+
}
81
+
82
+
did := userSession.Values[appview.SessionDid].(string)
83
+
84
+
err = db.DeleteOAuthSessionByDid(o.Db, did)
85
+
if err != nil {
86
+
return fmt.Errorf("error deleting oauth session: %w", err)
87
+
}
88
+
89
+
userSession.Options.MaxAge = -1
90
+
91
+
return userSession.Save(r, w)
92
+
}
93
+
94
+
func (o *OAuth) GetSession(r *http.Request) (*db.OAuthSession, bool, error) {
95
+
userSession, err := o.Store.Get(r, appview.SessionName)
96
+
if err != nil || userSession.IsNew {
97
+
return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err)
98
+
}
99
+
100
+
did := userSession.Values[appview.SessionDid].(string)
101
+
auth := userSession.Values[appview.SessionAuthenticated].(bool)
102
+
103
+
session, err := db.GetOAuthSessionByDid(o.Db, did)
104
+
if err != nil {
105
+
return nil, false, fmt.Errorf("error getting oauth session: %w", err)
106
+
}
107
+
108
+
expiry, err := time.Parse(time.RFC3339, session.Expiry)
109
+
if err != nil {
110
+
return nil, false, fmt.Errorf("error parsing expiry time: %w", err)
111
+
}
112
+
if expiry.Sub(time.Now()) <= 5*time.Minute {
113
+
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
114
+
if err != nil {
115
+
return nil, false, err
116
+
}
117
+
118
+
self := o.ClientMetadata()
119
+
120
+
oauthClient, err := client.NewClient(
121
+
self.ClientID,
122
+
o.Config.OAuth.Jwks,
123
+
self.RedirectURIs[0],
124
+
)
125
+
126
+
if err != nil {
127
+
return nil, false, err
128
+
}
129
+
130
+
resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk)
131
+
if err != nil {
132
+
return nil, false, err
133
+
}
134
+
135
+
newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339)
136
+
err = db.RefreshOAuthSession(o.Db, did, resp.AccessToken, resp.RefreshToken, newExpiry)
137
+
if err != nil {
138
+
return nil, false, fmt.Errorf("error refreshing oauth session: %w", err)
139
+
}
140
+
141
+
// update the current session
142
+
session.AccessJwt = resp.AccessToken
143
+
session.RefreshJwt = resp.RefreshToken
144
+
session.DpopAuthserverNonce = resp.DpopAuthserverNonce
145
+
session.Expiry = newExpiry
146
+
}
147
+
148
+
return session, auth, nil
149
+
}
150
+
151
+
type User struct {
152
+
Handle string
153
+
Did string
154
+
Pds string
155
+
}
156
+
157
+
func (a *OAuth) GetUser(r *http.Request) *User {
158
+
clientSession, err := a.Store.Get(r, appview.SessionName)
159
+
160
+
if err != nil || clientSession.IsNew {
161
+
return nil
162
+
}
163
+
164
+
return &User{
165
+
Handle: clientSession.Values[appview.SessionHandle].(string),
166
+
Did: clientSession.Values[appview.SessionDid].(string),
167
+
Pds: clientSession.Values[appview.SessionPds].(string),
168
+
}
169
+
}
170
+
171
+
func (a *OAuth) GetDid(r *http.Request) string {
172
+
clientSession, err := a.Store.Get(r, appview.SessionName)
173
+
174
+
if err != nil || clientSession.IsNew {
175
+
return ""
176
+
}
177
+
178
+
return clientSession.Values[appview.SessionDid].(string)
179
+
}
180
+
181
+
func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) {
182
+
session, auth, err := o.GetSession(r)
183
+
if err != nil {
184
+
return nil, fmt.Errorf("error getting session: %w", err)
185
+
}
186
+
if !auth {
187
+
return nil, fmt.Errorf("not authorized")
188
+
}
189
+
190
+
client := &oauth.XrpcClient{
191
+
OnDpopPdsNonceChanged: func(did, newNonce string) {
192
+
err := db.UpdateDpopPdsNonce(o.Db, did, newNonce)
193
+
if err != nil {
194
+
log.Printf("error updating dpop pds nonce: %v", err)
195
+
}
196
+
},
197
+
}
198
+
199
+
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
200
+
if err != nil {
201
+
return nil, fmt.Errorf("error parsing private jwk: %w", err)
202
+
}
203
+
204
+
xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{
205
+
Did: session.Did,
206
+
PdsUrl: session.PdsUrl,
207
+
DpopPdsNonce: session.PdsUrl,
208
+
AccessToken: session.AccessJwt,
209
+
Issuer: session.AuthServerIss,
210
+
DpopPrivateJwk: privateJwk,
211
+
})
212
+
213
+
return xrpcClient, nil
214
+
}
215
+
216
+
type ClientMetadata struct {
217
+
ClientID string `json:"client_id"`
218
+
ClientName string `json:"client_name"`
219
+
SubjectType string `json:"subject_type"`
220
+
ClientURI string `json:"client_uri"`
221
+
RedirectURIs []string `json:"redirect_uris"`
222
+
GrantTypes []string `json:"grant_types"`
223
+
ResponseTypes []string `json:"response_types"`
224
+
ApplicationType string `json:"application_type"`
225
+
DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
226
+
JwksURI string `json:"jwks_uri"`
227
+
Scope string `json:"scope"`
228
+
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
229
+
TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
230
+
}
231
+
232
+
func (o *OAuth) ClientMetadata() ClientMetadata {
233
+
makeRedirectURIs := func(c string) []string {
234
+
return []string{fmt.Sprintf("%s/oauth/callback", c)}
235
+
}
236
+
237
+
clientURI := o.Config.Core.AppviewHost
238
+
clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI)
239
+
redirectURIs := makeRedirectURIs(clientURI)
240
+
241
+
if o.Config.Core.Dev {
242
+
clientURI = fmt.Sprintf("http://127.0.0.1:3000")
243
+
redirectURIs = makeRedirectURIs(clientURI)
244
+
245
+
query := url.Values{}
246
+
query.Add("redirect_uri", redirectURIs[0])
247
+
query.Add("scope", "atproto transition:generic")
248
+
clientID = fmt.Sprintf("http://localhost?%s", query.Encode())
249
+
}
250
+
251
+
jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI)
252
+
253
+
return ClientMetadata{
254
+
ClientID: clientID,
255
+
ClientName: "Tangled",
256
+
SubjectType: "public",
257
+
ClientURI: clientURI,
258
+
RedirectURIs: redirectURIs,
259
+
GrantTypes: []string{"authorization_code", "refresh_token"},
260
+
ResponseTypes: []string{"code"},
261
+
ApplicationType: "web",
262
+
DpopBoundAccessTokens: true,
263
+
JwksURI: jwksURI,
264
+
Scope: "atproto transition:generic",
265
+
TokenEndpointAuthMethod: "private_key_jwt",
266
+
TokenEndpointAuthSigningAlg: "ES256",
267
+
}
268
+
}
+14
-1
appview/pages/funcmap.go
+14
-1
appview/pages/funcmap.go
···
13
"time"
14
15
"github.com/dustin/go-humanize"
16
)
17
18
func funcMap() template.FuncMap {
···
30
return strings.Split(s, sep)
31
},
32
"add": func(a, b int) int {
33
return a + b
34
},
35
"sub": func(a, b int) int {
···
68
return s
69
},
70
"timeFmt": humanize.Time,
71
"shortTimeFmt": func(t time.Time) string {
72
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
73
{time.Second, "now", time.Second},
···
134
return v.Slice(start, end).Interface()
135
},
136
"markdown": func(text string) template.HTML {
137
-
return template.HTML(renderMarkdown(text))
138
},
139
"isNil": func(t any) bool {
140
// returns false for other "zero" values
···
165
}
166
return template.HTML(data)
167
},
168
}
169
}
170
···
13
"time"
14
15
"github.com/dustin/go-humanize"
16
+
"github.com/microcosm-cc/bluemonday"
17
+
"tangled.sh/tangled.sh/core/appview/filetree"
18
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
19
)
20
21
func funcMap() template.FuncMap {
···
33
return strings.Split(s, sep)
34
},
35
"add": func(a, b int) int {
36
+
return a + b
37
+
},
38
+
// the absolute state of go templates
39
+
"add64": func(a, b int64) int64 {
40
return a + b
41
},
42
"sub": func(a, b int) int {
···
75
return s
76
},
77
"timeFmt": humanize.Time,
78
+
"longTimeFmt": func(t time.Time) string {
79
+
return t.Format("2006-01-02 * 3:04 PM")
80
+
},
81
"shortTimeFmt": func(t time.Time) string {
82
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
83
{time.Second, "now", time.Second},
···
144
return v.Slice(start, end).Interface()
145
},
146
"markdown": func(text string) template.HTML {
147
+
rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault}
148
+
return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text)))
149
},
150
"isNil": func(t any) bool {
151
// returns false for other "zero" values
···
176
}
177
return template.HTML(data)
178
},
179
+
"cssContentHash": CssContentHash,
180
+
"fileTree": filetree.FileTree,
181
}
182
}
183
-23
appview/pages/markdown.go
-23
appview/pages/markdown.go
···
1
-
package pages
2
-
3
-
import (
4
-
"bytes"
5
-
6
-
"github.com/yuin/goldmark"
7
-
"github.com/yuin/goldmark/extension"
8
-
"github.com/yuin/goldmark/parser"
9
-
)
10
-
11
-
func renderMarkdown(source string) string {
12
-
md := goldmark.New(
13
-
goldmark.WithExtensions(extension.GFM),
14
-
goldmark.WithParserOptions(
15
-
parser.WithAutoHeadingID(),
16
-
),
17
-
)
18
-
var buf bytes.Buffer
19
-
if err := md.Convert([]byte(source), &buf); err != nil {
20
-
return source
21
-
}
22
-
return buf.String()
23
-
}
···
+31
appview/pages/markup/camo.go
+31
appview/pages/markup/camo.go
···
···
1
+
package markup
2
+
3
+
import (
4
+
"crypto/hmac"
5
+
"crypto/sha256"
6
+
"encoding/hex"
7
+
"fmt"
8
+
9
+
"github.com/yuin/goldmark/ast"
10
+
)
11
+
12
+
func generateCamoURL(baseURL, secret, imageURL string) string {
13
+
h := hmac.New(sha256.New, []byte(secret))
14
+
h.Write([]byte(imageURL))
15
+
signature := hex.EncodeToString(h.Sum(nil))
16
+
hexURL := hex.EncodeToString([]byte(imageURL))
17
+
return fmt.Sprintf("%s/%s/%s", baseURL, signature, hexURL)
18
+
}
19
+
20
+
func (rctx *RenderContext) camoImageLinkTransformer(img *ast.Image) {
21
+
// don't camo on dev
22
+
if rctx.IsDev {
23
+
return
24
+
}
25
+
26
+
dst := string(img.Destination)
27
+
28
+
if rctx.CamoUrl != "" && rctx.CamoSecret != "" {
29
+
img.Destination = []byte(generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst))
30
+
}
31
+
}
+26
appview/pages/markup/format.go
+26
appview/pages/markup/format.go
···
···
1
+
package markup
2
+
3
+
import "strings"
4
+
5
+
type Format string
6
+
7
+
const (
8
+
FormatMarkdown Format = "markdown"
9
+
FormatText Format = "text"
10
+
)
11
+
12
+
var FileTypes map[Format][]string = map[Format][]string{
13
+
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
14
+
}
15
+
16
+
func GetFormat(filename string) Format {
17
+
for format, extensions := range FileTypes {
18
+
for _, extension := range extensions {
19
+
if strings.HasSuffix(filename, extension) {
20
+
return format
21
+
}
22
+
}
23
+
}
24
+
// default format
25
+
return FormatText
26
+
}
+144
appview/pages/markup/markdown.go
+144
appview/pages/markup/markdown.go
···
···
1
+
// Package markup is an umbrella package for all markups and their renderers.
2
+
package markup
3
+
4
+
import (
5
+
"bytes"
6
+
"net/url"
7
+
"path"
8
+
9
+
"github.com/yuin/goldmark"
10
+
"github.com/yuin/goldmark/ast"
11
+
"github.com/yuin/goldmark/extension"
12
+
"github.com/yuin/goldmark/parser"
13
+
"github.com/yuin/goldmark/renderer/html"
14
+
"github.com/yuin/goldmark/text"
15
+
"github.com/yuin/goldmark/util"
16
+
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
17
+
)
18
+
19
+
// RendererType defines the type of renderer to use based on context
20
+
type RendererType int
21
+
22
+
const (
23
+
// RendererTypeRepoMarkdown is for repository documentation markdown files
24
+
RendererTypeRepoMarkdown RendererType = iota
25
+
// RendererTypeDefault is non-repo markdown, like issues/pulls/comments.
26
+
RendererTypeDefault
27
+
)
28
+
29
+
// RenderContext holds the contextual data for rendering markdown.
30
+
// It can be initialized empty, and that'll skip any transformations.
31
+
type RenderContext struct {
32
+
CamoUrl string
33
+
CamoSecret string
34
+
repoinfo.RepoInfo
35
+
IsDev bool
36
+
RendererType RendererType
37
+
}
38
+
39
+
func (rctx *RenderContext) RenderMarkdown(source string) string {
40
+
md := goldmark.New(
41
+
goldmark.WithExtensions(extension.GFM),
42
+
goldmark.WithParserOptions(
43
+
parser.WithAutoHeadingID(),
44
+
),
45
+
goldmark.WithRendererOptions(html.WithUnsafe()),
46
+
)
47
+
48
+
if rctx != nil {
49
+
var transformers []util.PrioritizedValue
50
+
51
+
transformers = append(transformers, util.Prioritized(&MarkdownTransformer{rctx: rctx}, 10000))
52
+
53
+
md.Parser().AddOptions(
54
+
parser.WithASTTransformers(transformers...),
55
+
)
56
+
}
57
+
58
+
var buf bytes.Buffer
59
+
if err := md.Convert([]byte(source), &buf); err != nil {
60
+
return source
61
+
}
62
+
return buf.String()
63
+
}
64
+
65
+
type MarkdownTransformer struct {
66
+
rctx *RenderContext
67
+
}
68
+
69
+
func (a *MarkdownTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
70
+
_ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
71
+
if !entering {
72
+
return ast.WalkContinue, nil
73
+
}
74
+
75
+
switch a.rctx.RendererType {
76
+
case RendererTypeRepoMarkdown:
77
+
switch n.(type) {
78
+
case *ast.Link:
79
+
a.rctx.relativeLinkTransformer(n.(*ast.Link))
80
+
case *ast.Image:
81
+
a.rctx.imageFromKnotTransformer(n.(*ast.Image))
82
+
a.rctx.camoImageLinkTransformer(n.(*ast.Image))
83
+
}
84
+
85
+
case RendererTypeDefault:
86
+
switch n.(type) {
87
+
case *ast.Image:
88
+
a.rctx.imageFromKnotTransformer(n.(*ast.Image))
89
+
a.rctx.camoImageLinkTransformer(n.(*ast.Image))
90
+
}
91
+
}
92
+
93
+
return ast.WalkContinue, nil
94
+
})
95
+
}
96
+
97
+
func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) {
98
+
dst := string(link.Destination)
99
+
100
+
if isAbsoluteUrl(dst) {
101
+
return
102
+
}
103
+
104
+
newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, dst)
105
+
link.Destination = []byte(newPath)
106
+
}
107
+
108
+
func (rctx *RenderContext) imageFromKnotTransformer(img *ast.Image) {
109
+
dst := string(img.Destination)
110
+
111
+
if isAbsoluteUrl(dst) {
112
+
return
113
+
}
114
+
115
+
// strip leading './'
116
+
if len(dst) >= 2 && dst[0:2] == "./" {
117
+
dst = dst[2:]
118
+
}
119
+
120
+
scheme := "https"
121
+
if rctx.IsDev {
122
+
scheme = "http"
123
+
}
124
+
parsedURL := &url.URL{
125
+
Scheme: scheme,
126
+
Host: rctx.Knot,
127
+
Path: path.Join("/",
128
+
rctx.RepoInfo.OwnerDid,
129
+
rctx.RepoInfo.Name,
130
+
"raw",
131
+
url.PathEscape(rctx.RepoInfo.Ref),
132
+
dst),
133
+
}
134
+
newPath := parsedURL.String()
135
+
img.Destination = []byte(newPath)
136
+
}
137
+
138
+
func isAbsoluteUrl(link string) bool {
139
+
parsed, err := url.Parse(link)
140
+
if err != nil {
141
+
return false
142
+
}
143
+
return parsed.IsAbs()
144
+
}
+472
-221
appview/pages/pages.go
+472
-221
appview/pages/pages.go
···
2
3
import (
4
"bytes"
5
"embed"
6
"fmt"
7
"html/template"
8
"io"
9
"io/fs"
10
"log"
11
"net/http"
12
-
"path"
13
"path/filepath"
14
-
"slices"
15
"strings"
16
17
"github.com/alecthomas/chroma/v2"
18
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
19
"github.com/alecthomas/chroma/v2/lexers"
20
"github.com/alecthomas/chroma/v2/styles"
21
"github.com/bluesky-social/indigo/atproto/syntax"
22
"github.com/microcosm-cc/bluemonday"
23
-
"tangled.sh/tangled.sh/core/appview/auth"
24
-
"tangled.sh/tangled.sh/core/appview/db"
25
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
26
-
"tangled.sh/tangled.sh/core/types"
27
)
28
29
//go:embed templates/* static
30
var Files embed.FS
31
32
type Pages struct {
33
-
t map[string]*template.Template
34
}
35
36
-
func NewPages() *Pages {
37
templates := make(map[string]*template.Template)
38
39
-
// Walk through embedded templates directory and parse all .html files
40
-
err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
41
if err != nil {
42
return err
43
}
44
-
45
-
if !d.IsDir() && strings.HasSuffix(path, ".html") {
46
-
name := strings.TrimPrefix(path, "templates/")
47
-
name = strings.TrimSuffix(name, ".html")
48
49
-
// add fragments as templates
50
-
if strings.HasPrefix(path, "templates/fragments/") {
51
-
tmpl, err := template.New(name).
52
-
Funcs(funcMap()).
53
-
ParseFS(Files, path)
54
-
if err != nil {
55
-
return fmt.Errorf("setting up fragment: %w", err)
56
-
}
57
58
-
templates[name] = tmpl
59
-
log.Printf("loaded fragment: %s", name)
60
-
}
61
62
-
// layouts and fragments are applied first
63
-
if !strings.HasPrefix(path, "templates/layouts/") &&
64
-
!strings.HasPrefix(path, "templates/fragments/") {
65
-
// Add the page template on top of the base
66
-
tmpl, err := template.New(name).
67
-
Funcs(funcMap()).
68
-
ParseFS(Files, "templates/layouts/*.html", "templates/fragments/*.html", path)
69
-
if err != nil {
70
-
return fmt.Errorf("setting up template: %w", err)
71
-
}
72
73
-
templates[name] = tmpl
74
-
log.Printf("loaded template: %s", name)
75
-
}
76
77
return nil
78
}
79
return nil
80
})
81
if err != nil {
82
-
log.Fatalf("walking template dir: %v", err)
83
}
84
85
-
log.Printf("total templates loaded: %d", len(templates))
86
87
-
return &Pages{
88
-
t: templates,
89
}
90
}
91
92
-
type LoginParams struct {
93
}
94
95
func (p *Pages) execute(name string, w io.Writer, params any) error {
96
-
return p.t[name].ExecuteTemplate(w, "layouts/base", params)
97
}
98
99
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
100
-
return p.t[name].Execute(w, params)
101
}
102
103
func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
104
-
return p.t[name].ExecuteTemplate(w, "layouts/repobase", params)
105
}
106
107
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
109
}
110
111
type TimelineParams struct {
112
-
LoggedInUser *auth.User
113
Timeline []db.TimelineEvent
114
DidHandleMap map[string]string
115
}
···
119
}
120
121
type SettingsParams struct {
122
-
LoggedInUser *auth.User
123
PubKeys []db.PublicKey
124
Emails []db.Email
125
}
···
129
}
130
131
type KnotsParams struct {
132
-
LoggedInUser *auth.User
133
Registrations []db.Registration
134
}
135
···
138
}
139
140
type KnotParams struct {
141
-
LoggedInUser *auth.User
142
Registration *db.Registration
143
Members []string
144
IsOwner bool
···
149
}
150
151
type NewRepoParams struct {
152
-
LoggedInUser *auth.User
153
Knots []string
154
}
155
···
157
return p.execute("repo/new", w, params)
158
}
159
160
type ProfilePageParams struct {
161
-
LoggedInUser *auth.User
162
-
UserDid string
163
-
UserHandle string
164
Repos []db.Repo
165
CollaboratingRepos []db.Repo
166
-
ProfileStats ProfileStats
167
-
FollowStatus db.FollowStatus
168
-
DidHandleMap map[string]string
169
-
AvatarUri string
170
-
}
171
172
-
type ProfileStats struct {
173
-
Followers int
174
-
Following int
175
-
}
176
-
177
-
func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error {
178
-
return p.execute("user/profile", w, params)
179
}
180
181
-
type FollowFragmentParams struct {
182
UserDid string
183
FollowStatus db.FollowStatus
184
-
}
185
186
-
func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
187
-
return p.executePlain("fragments/follow", w, params)
188
}
189
190
-
type StarFragmentParams struct {
191
-
IsStarred bool
192
-
RepoAt syntax.ATURI
193
-
Stats db.RepoStats
194
}
195
196
-
func (p *Pages) StarFragment(w io.Writer, params StarFragmentParams) error {
197
-
return p.executePlain("fragments/star", w, params)
198
-
}
199
200
-
type RepoDescriptionParams struct {
201
-
RepoInfo RepoInfo
202
}
203
204
-
func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
205
-
return p.executePlain("fragments/editRepoDescription", w, params)
206
}
207
208
-
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
209
-
return p.executePlain("fragments/repoDescription", w, params)
210
}
211
212
-
type RepoInfo struct {
213
-
Name string
214
-
OwnerDid string
215
-
OwnerHandle string
216
-
Description string
217
-
Knot string
218
-
RepoAt syntax.ATURI
219
-
IsStarred bool
220
-
Stats db.RepoStats
221
-
Roles RolesInRepo
222
}
223
224
-
type RolesInRepo struct {
225
-
Roles []string
226
}
227
228
-
func (r RolesInRepo) SettingsAllowed() bool {
229
-
return slices.Contains(r.Roles, "repo:settings")
230
}
231
232
-
func (r RolesInRepo) IsOwner() bool {
233
-
return slices.Contains(r.Roles, "repo:owner")
234
}
235
236
-
func (r RolesInRepo) IsCollaborator() bool {
237
-
return slices.Contains(r.Roles, "repo:collaborator")
238
}
239
240
-
func (r RolesInRepo) IsPushAllowed() bool {
241
-
return slices.Contains(r.Roles, "repo:push")
242
}
243
244
-
func (r RepoInfo) OwnerWithAt() string {
245
-
if r.OwnerHandle != "" {
246
-
return fmt.Sprintf("@%s", r.OwnerHandle)
247
-
} else {
248
-
return r.OwnerDid
249
-
}
250
}
251
252
-
func (r RepoInfo) FullName() string {
253
-
return path.Join(r.OwnerWithAt(), r.Name)
254
}
255
256
-
func (r RepoInfo) OwnerWithoutAt() string {
257
-
if strings.HasPrefix(r.OwnerWithAt(), "@") {
258
-
return strings.TrimPrefix(r.OwnerWithAt(), "@")
259
-
} else {
260
-
return userutil.FlattenDid(r.OwnerDid)
261
-
}
262
}
263
264
-
func (r RepoInfo) FullNameWithoutAt() string {
265
-
return path.Join(r.OwnerWithoutAt(), r.Name)
266
}
267
268
-
func (r RepoInfo) GetTabs() [][]string {
269
-
tabs := [][]string{
270
-
{"overview", "/"},
271
-
{"issues", "/issues"},
272
-
{"pulls", "/pulls"},
273
-
}
274
-
275
-
if r.Roles.SettingsAllowed() {
276
-
tabs = append(tabs, []string{"settings", "/settings"})
277
-
}
278
-
279
-
return tabs
280
-
}
281
-
282
-
// each tab on a repo could have some metadata:
283
-
//
284
-
// issues -> number of open issues etc.
285
-
// settings -> a warning icon to setup branch protection? idk
286
-
//
287
-
// we gather these bits of info here, because go templates
288
-
// are difficult to program in
289
-
func (r RepoInfo) TabMetadata() map[string]any {
290
-
meta := make(map[string]any)
291
-
292
-
if r.Stats.PullCount.Open > 0 {
293
-
meta["pulls"] = r.Stats.PullCount.Open
294
-
}
295
-
296
-
if r.Stats.IssueCount.Open > 0 {
297
-
meta["issues"] = r.Stats.IssueCount.Open
298
-
}
299
-
300
-
// more stuff?
301
-
302
-
return meta
303
}
304
305
type RepoIndexParams struct {
306
-
LoggedInUser *auth.User
307
-
RepoInfo RepoInfo
308
-
Active string
309
-
TagMap map[string][]string
310
types.RepoIndexResponse
311
HTMLReadme template.HTML
312
Raw bool
···
319
return p.executeRepo("repo/empty", w, params)
320
}
321
322
if params.ReadmeFileName != "" {
323
var htmlString string
324
ext := filepath.Ext(params.ReadmeFileName)
325
switch ext {
326
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
327
-
htmlString = renderMarkdown(params.Readme)
328
params.Raw = false
329
params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString))
330
default:
···
338
}
339
340
type RepoLogParams struct {
341
-
LoggedInUser *auth.User
342
-
RepoInfo RepoInfo
343
types.RepoLogResponse
344
Active string
345
EmailToDidOrHandle map[string]string
···
347
348
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
349
params.Active = "overview"
350
-
return p.execute("repo/log", w, params)
351
}
352
353
type RepoCommitParams struct {
354
-
LoggedInUser *auth.User
355
-
RepoInfo RepoInfo
356
-
Active string
357
types.RepoCommitResponse
358
-
EmailToDidOrHandle map[string]string
359
}
360
361
func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
···
364
}
365
366
type RepoTreeParams struct {
367
-
LoggedInUser *auth.User
368
-
RepoInfo RepoInfo
369
Active string
370
BreadCrumbs [][]string
371
BaseTreeLink string
···
400
}
401
402
type RepoBranchesParams struct {
403
-
LoggedInUser *auth.User
404
-
RepoInfo RepoInfo
405
types.RepoBranchesResponse
406
}
407
408
func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
409
return p.executeRepo("repo/branches", w, params)
410
}
411
412
type RepoTagsParams struct {
413
-
LoggedInUser *auth.User
414
-
RepoInfo RepoInfo
415
types.RepoTagsResponse
416
}
417
418
func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
419
return p.executeRepo("repo/tags", w, params)
420
}
421
422
type RepoBlobParams struct {
423
-
LoggedInUser *auth.User
424
-
RepoInfo RepoInfo
425
-
Active string
426
-
BreadCrumbs [][]string
427
types.RepoBlobResponse
428
}
429
430
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
431
-
style := styles.Get("bw")
432
-
b := style.Builder()
433
-
b.Add(chroma.LiteralString, "noitalic")
434
-
style, _ = b.Build()
435
436
if params.Lines < 5000 {
437
c := params.Contents
438
formatter := chromahtml.New(
439
-
chromahtml.InlineCode(true),
440
chromahtml.WithLineNumbers(true),
441
chromahtml.WithLinkableLineNumbers(true, "L"),
442
chromahtml.Standalone(false),
443
)
444
445
lexer := lexers.Get(filepath.Base(params.Path))
···
472
}
473
474
type RepoSettingsParams struct {
475
-
LoggedInUser *auth.User
476
-
RepoInfo RepoInfo
477
Collaborators []Collaborator
478
Active string
479
// TODO: use repoinfo.roles
480
IsCollaboratorInviteAllowed bool
481
}
···
486
}
487
488
type RepoIssuesParams struct {
489
-
LoggedInUser *auth.User
490
-
RepoInfo RepoInfo
491
-
Active string
492
-
Issues []db.Issue
493
-
DidHandleMap map[string]string
494
-
495
FilteringByOpen bool
496
}
497
···
501
}
502
503
type RepoSingleIssueParams struct {
504
-
LoggedInUser *auth.User
505
-
RepoInfo RepoInfo
506
Active string
507
Issue db.Issue
508
Comments []db.Comment
···
523
}
524
525
type RepoNewIssueParams struct {
526
-
LoggedInUser *auth.User
527
-
RepoInfo RepoInfo
528
Active string
529
}
530
···
533
return p.executeRepo("repo/issues/new", w, params)
534
}
535
536
type RepoNewPullParams struct {
537
-
LoggedInUser *auth.User
538
-
RepoInfo RepoInfo
539
Branches []types.Branch
540
Active string
541
}
···
546
}
547
548
type RepoPullsParams struct {
549
-
LoggedInUser *auth.User
550
-
RepoInfo RepoInfo
551
-
Pulls []db.Pull
552
Active string
553
DidHandleMap map[string]string
554
FilteringBy db.PullState
···
559
return p.executeRepo("repo/pulls/pulls", w, params)
560
}
561
562
-
type RepoSinglePullParams struct {
563
-
LoggedInUser *auth.User
564
-
RepoInfo RepoInfo
565
-
Active string
566
-
DidHandleMap map[string]string
567
568
-
Pull db.Pull
569
-
MergeCheck types.MergeCheckResponse
570
}
571
572
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
575
}
576
577
type RepoPullPatchParams struct {
578
-
LoggedInUser *auth.User
579
DidHandleMap map[string]string
580
-
RepoInfo RepoInfo
581
Pull *db.Pull
582
-
Diff types.NiceDiff
583
Round int
584
Submission *db.PullSubmission
585
}
···
589
return p.execute("repo/pulls/patch", w, params)
590
}
591
592
type PullResubmitParams struct {
593
-
LoggedInUser *auth.User
594
-
RepoInfo RepoInfo
595
Pull *db.Pull
596
SubmissionId int
597
}
598
599
func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
600
-
return p.executePlain("fragments/pullResubmit", w, params)
601
}
602
603
type PullActionsParams struct {
604
-
LoggedInUser *auth.User
605
-
RepoInfo RepoInfo
606
-
Pull *db.Pull
607
-
RoundNumber int
608
-
MergeCheck types.MergeCheckResponse
609
}
610
611
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
612
-
return p.executePlain("fragments/pullActions", w, params)
613
}
614
615
type PullNewCommentParams struct {
616
-
LoggedInUser *auth.User
617
-
RepoInfo RepoInfo
618
Pull *db.Pull
619
RoundNumber int
620
}
621
622
func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
623
-
return p.executePlain("fragments/pullNewComment", w, params)
624
}
625
626
func (p *Pages) Static() http.Handler {
627
sub, err := fs.Sub(Files, "static")
628
if err != nil {
629
log.Fatalf("no static dir found? that's crazy: %v", err)
···
634
635
func Cache(h http.Handler) http.Handler {
636
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
637
-
if strings.HasSuffix(r.URL.Path, ".css") {
638
// on day for css files
639
w.Header().Set("Cache-Control", "public, max-age=86400")
640
} else {
···
642
}
643
h.ServeHTTP(w, r)
644
})
645
}
646
647
func (p *Pages) Error500(w io.Writer) error {
···
2
3
import (
4
"bytes"
5
+
"crypto/sha256"
6
"embed"
7
+
"encoding/hex"
8
"fmt"
9
"html/template"
10
"io"
11
"io/fs"
12
"log"
13
"net/http"
14
+
"os"
15
"path/filepath"
16
"strings"
17
18
+
"tangled.sh/tangled.sh/core/appview"
19
+
"tangled.sh/tangled.sh/core/appview/db"
20
+
"tangled.sh/tangled.sh/core/appview/oauth"
21
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
22
+
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
23
+
"tangled.sh/tangled.sh/core/appview/pagination"
24
+
"tangled.sh/tangled.sh/core/patchutil"
25
+
"tangled.sh/tangled.sh/core/types"
26
+
27
"github.com/alecthomas/chroma/v2"
28
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
29
"github.com/alecthomas/chroma/v2/lexers"
30
"github.com/alecthomas/chroma/v2/styles"
31
"github.com/bluesky-social/indigo/atproto/syntax"
32
+
"github.com/go-git/go-git/v5/plumbing"
33
+
"github.com/go-git/go-git/v5/plumbing/object"
34
"github.com/microcosm-cc/bluemonday"
35
)
36
37
//go:embed templates/* static
38
var Files embed.FS
39
40
type Pages struct {
41
+
t map[string]*template.Template
42
+
dev bool
43
+
embedFS embed.FS
44
+
templateDir string // Path to templates on disk for dev mode
45
+
rctx *markup.RenderContext
46
+
}
47
+
48
+
func NewPages(config *appview.Config) *Pages {
49
+
// initialized with safe defaults, can be overriden per use
50
+
rctx := &markup.RenderContext{
51
+
IsDev: config.Core.Dev,
52
+
CamoUrl: config.Camo.Host,
53
+
CamoSecret: config.Camo.SharedSecret,
54
+
}
55
+
56
+
p := &Pages{
57
+
t: make(map[string]*template.Template),
58
+
dev: config.Core.Dev,
59
+
embedFS: Files,
60
+
rctx: rctx,
61
+
templateDir: "appview/pages",
62
+
}
63
+
64
+
// Initial load of all templates
65
+
p.loadAllTemplates()
66
+
67
+
return p
68
}
69
70
+
func (p *Pages) loadAllTemplates() {
71
templates := make(map[string]*template.Template)
72
+
var fragmentPaths []string
73
74
+
// Use embedded FS for initial loading
75
+
// First, collect all fragment paths
76
+
err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
77
if err != nil {
78
return err
79
}
80
+
if d.IsDir() {
81
+
return nil
82
+
}
83
+
if !strings.HasSuffix(path, ".html") {
84
+
return nil
85
+
}
86
+
if !strings.Contains(path, "fragments/") {
87
+
return nil
88
+
}
89
+
name := strings.TrimPrefix(path, "templates/")
90
+
name = strings.TrimSuffix(name, ".html")
91
+
tmpl, err := template.New(name).
92
+
Funcs(funcMap()).
93
+
ParseFS(p.embedFS, path)
94
+
if err != nil {
95
+
log.Fatalf("setting up fragment: %v", err)
96
+
}
97
+
templates[name] = tmpl
98
+
fragmentPaths = append(fragmentPaths, path)
99
+
log.Printf("loaded fragment: %s", name)
100
+
return nil
101
+
})
102
+
if err != nil {
103
+
log.Fatalf("walking template dir for fragments: %v", err)
104
+
}
105
106
+
// Then walk through and setup the rest of the templates
107
+
err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
108
+
if err != nil {
109
+
return err
110
+
}
111
+
if d.IsDir() {
112
+
return nil
113
+
}
114
+
if !strings.HasSuffix(path, "html") {
115
+
return nil
116
+
}
117
+
// Skip fragments as they've already been loaded
118
+
if strings.Contains(path, "fragments/") {
119
+
return nil
120
+
}
121
+
// Skip layouts
122
+
if strings.Contains(path, "layouts/") {
123
+
return nil
124
+
}
125
+
name := strings.TrimPrefix(path, "templates/")
126
+
name = strings.TrimSuffix(name, ".html")
127
+
// Add the page template on top of the base
128
+
allPaths := []string{}
129
+
allPaths = append(allPaths, "templates/layouts/*.html")
130
+
allPaths = append(allPaths, fragmentPaths...)
131
+
allPaths = append(allPaths, path)
132
+
tmpl, err := template.New(name).
133
+
Funcs(funcMap()).
134
+
ParseFS(p.embedFS, allPaths...)
135
+
if err != nil {
136
+
return fmt.Errorf("setting up template: %w", err)
137
+
}
138
+
templates[name] = tmpl
139
+
log.Printf("loaded template: %s", name)
140
+
return nil
141
+
})
142
+
if err != nil {
143
+
log.Fatalf("walking template dir: %v", err)
144
+
}
145
146
+
log.Printf("total templates loaded: %d", len(templates))
147
+
p.t = templates
148
+
}
149
150
+
// loadTemplateFromDisk loads a template from the filesystem in dev mode
151
+
func (p *Pages) loadTemplateFromDisk(name string) error {
152
+
if !p.dev {
153
+
return nil
154
+
}
155
156
+
log.Printf("reloading template from disk: %s", name)
157
158
+
// Find all fragments first
159
+
var fragmentPaths []string
160
+
err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error {
161
+
if err != nil {
162
+
return err
163
+
}
164
+
if d.IsDir() {
165
return nil
166
}
167
+
if !strings.HasSuffix(path, ".html") {
168
+
return nil
169
+
}
170
+
if !strings.Contains(path, "fragments/") {
171
+
return nil
172
+
}
173
+
fragmentPaths = append(fragmentPaths, path)
174
return nil
175
})
176
if err != nil {
177
+
return fmt.Errorf("walking disk template dir for fragments: %w", err)
178
+
}
179
+
180
+
// Find the template path on disk
181
+
templatePath := filepath.Join(p.templateDir, "templates", name+".html")
182
+
if _, err := os.Stat(templatePath); os.IsNotExist(err) {
183
+
return fmt.Errorf("template not found on disk: %s", name)
184
}
185
186
+
// Create a new template
187
+
tmpl := template.New(name).Funcs(funcMap())
188
189
+
// Parse layouts
190
+
layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html")
191
+
layouts, err := filepath.Glob(layoutGlob)
192
+
if err != nil {
193
+
return fmt.Errorf("finding layout templates: %w", err)
194
}
195
+
196
+
// Create paths for parsing
197
+
allFiles := append(layouts, fragmentPaths...)
198
+
allFiles = append(allFiles, templatePath)
199
+
200
+
// Parse all templates
201
+
tmpl, err = tmpl.ParseFiles(allFiles...)
202
+
if err != nil {
203
+
return fmt.Errorf("parsing template files: %w", err)
204
+
}
205
+
206
+
// Update the template in the map
207
+
p.t[name] = tmpl
208
+
log.Printf("template reloaded from disk: %s", name)
209
+
return nil
210
}
211
212
+
func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error {
213
+
// In dev mode, reload the template from disk before executing
214
+
if p.dev {
215
+
if err := p.loadTemplateFromDisk(templateName); err != nil {
216
+
log.Printf("warning: failed to reload template %s from disk: %v", templateName, err)
217
+
// Continue with the existing template
218
+
}
219
+
}
220
+
221
+
tmpl, exists := p.t[templateName]
222
+
if !exists {
223
+
return fmt.Errorf("template not found: %s", templateName)
224
+
}
225
+
226
+
if base == "" {
227
+
return tmpl.Execute(w, params)
228
+
} else {
229
+
return tmpl.ExecuteTemplate(w, base, params)
230
+
}
231
}
232
233
func (p *Pages) execute(name string, w io.Writer, params any) error {
234
+
return p.executeOrReload(name, w, "layouts/base", params)
235
}
236
237
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
238
+
return p.executeOrReload(name, w, "", params)
239
}
240
241
func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
242
+
return p.executeOrReload(name, w, "layouts/repobase", params)
243
+
}
244
+
245
+
type LoginParams struct {
246
}
247
248
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
250
}
251
252
type TimelineParams struct {
253
+
LoggedInUser *oauth.User
254
Timeline []db.TimelineEvent
255
DidHandleMap map[string]string
256
}
···
260
}
261
262
type SettingsParams struct {
263
+
LoggedInUser *oauth.User
264
PubKeys []db.PublicKey
265
Emails []db.Email
266
}
···
270
}
271
272
type KnotsParams struct {
273
+
LoggedInUser *oauth.User
274
Registrations []db.Registration
275
}
276
···
279
}
280
281
type KnotParams struct {
282
+
LoggedInUser *oauth.User
283
+
DidHandleMap map[string]string
284
Registration *db.Registration
285
Members []string
286
IsOwner bool
···
291
}
292
293
type NewRepoParams struct {
294
+
LoggedInUser *oauth.User
295
Knots []string
296
}
297
···
299
return p.execute("repo/new", w, params)
300
}
301
302
+
type ForkRepoParams struct {
303
+
LoggedInUser *oauth.User
304
+
Knots []string
305
+
RepoInfo repoinfo.RepoInfo
306
+
}
307
+
308
+
func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
309
+
return p.execute("repo/fork", w, params)
310
+
}
311
+
312
type ProfilePageParams struct {
313
+
LoggedInUser *oauth.User
314
Repos []db.Repo
315
CollaboratingRepos []db.Repo
316
+
ProfileTimeline *db.ProfileTimeline
317
+
Card ProfileCard
318
319
+
DidHandleMap map[string]string
320
}
321
322
+
type ProfileCard struct {
323
UserDid string
324
+
UserHandle string
325
FollowStatus db.FollowStatus
326
+
AvatarUri string
327
+
Followers int
328
+
Following int
329
330
+
Profile *db.Profile
331
}
332
333
+
func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error {
334
+
return p.execute("user/profile", w, params)
335
}
336
337
+
type ReposPageParams struct {
338
+
LoggedInUser *oauth.User
339
+
Repos []db.Repo
340
+
Card ProfileCard
341
342
+
DidHandleMap map[string]string
343
}
344
345
+
func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error {
346
+
return p.execute("user/repos", w, params)
347
}
348
349
+
type FollowFragmentParams struct {
350
+
UserDid string
351
+
FollowStatus db.FollowStatus
352
}
353
354
+
func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
355
+
return p.executePlain("user/fragments/follow", w, params)
356
}
357
358
+
type EditBioParams struct {
359
+
LoggedInUser *oauth.User
360
+
Profile *db.Profile
361
}
362
363
+
func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
364
+
return p.executePlain("user/fragments/editBio", w, params)
365
}
366
367
+
type EditPinsParams struct {
368
+
LoggedInUser *oauth.User
369
+
Profile *db.Profile
370
+
AllRepos []PinnedRepo
371
+
DidHandleMap map[string]string
372
}
373
374
+
type PinnedRepo struct {
375
+
IsPinned bool
376
+
db.Repo
377
}
378
379
+
func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
380
+
return p.executePlain("user/fragments/editPins", w, params)
381
}
382
383
+
type RepoActionsFragmentParams struct {
384
+
IsStarred bool
385
+
RepoAt syntax.ATURI
386
+
Stats db.RepoStats
387
}
388
389
+
func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error {
390
+
return p.executePlain("repo/fragments/repoActions", w, params)
391
}
392
393
+
type RepoDescriptionParams struct {
394
+
RepoInfo repoinfo.RepoInfo
395
}
396
397
+
func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
398
+
return p.executePlain("repo/fragments/editRepoDescription", w, params)
399
}
400
401
+
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
402
+
return p.executePlain("repo/fragments/repoDescription", w, params)
403
}
404
405
type RepoIndexParams struct {
406
+
LoggedInUser *oauth.User
407
+
RepoInfo repoinfo.RepoInfo
408
+
Active string
409
+
TagMap map[string][]string
410
+
CommitsTrunc []*object.Commit
411
+
TagsTrunc []*types.TagReference
412
+
BranchesTrunc []types.Branch
413
types.RepoIndexResponse
414
HTMLReadme template.HTML
415
Raw bool
···
422
return p.executeRepo("repo/empty", w, params)
423
}
424
425
+
p.rctx.RepoInfo = params.RepoInfo
426
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
427
+
428
if params.ReadmeFileName != "" {
429
var htmlString string
430
ext := filepath.Ext(params.ReadmeFileName)
431
switch ext {
432
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
433
+
htmlString = p.rctx.RenderMarkdown(params.Readme)
434
params.Raw = false
435
params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString))
436
default:
···
444
}
445
446
type RepoLogParams struct {
447
+
LoggedInUser *oauth.User
448
+
RepoInfo repoinfo.RepoInfo
449
+
TagMap map[string][]string
450
types.RepoLogResponse
451
Active string
452
EmailToDidOrHandle map[string]string
···
454
455
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
456
params.Active = "overview"
457
+
return p.executeRepo("repo/log", w, params)
458
}
459
460
type RepoCommitParams struct {
461
+
LoggedInUser *oauth.User
462
+
RepoInfo repoinfo.RepoInfo
463
+
Active string
464
+
EmailToDidOrHandle map[string]string
465
+
466
types.RepoCommitResponse
467
}
468
469
func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
···
472
}
473
474
type RepoTreeParams struct {
475
+
LoggedInUser *oauth.User
476
+
RepoInfo repoinfo.RepoInfo
477
Active string
478
BreadCrumbs [][]string
479
BaseTreeLink string
···
508
}
509
510
type RepoBranchesParams struct {
511
+
LoggedInUser *oauth.User
512
+
RepoInfo repoinfo.RepoInfo
513
+
Active string
514
types.RepoBranchesResponse
515
}
516
517
func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
518
+
params.Active = "overview"
519
return p.executeRepo("repo/branches", w, params)
520
}
521
522
type RepoTagsParams struct {
523
+
LoggedInUser *oauth.User
524
+
RepoInfo repoinfo.RepoInfo
525
+
Active string
526
types.RepoTagsResponse
527
+
ArtifactMap map[plumbing.Hash][]db.Artifact
528
+
DanglingArtifacts []db.Artifact
529
}
530
531
func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
532
+
params.Active = "overview"
533
return p.executeRepo("repo/tags", w, params)
534
}
535
536
+
type RepoArtifactParams struct {
537
+
LoggedInUser *oauth.User
538
+
RepoInfo repoinfo.RepoInfo
539
+
Artifact db.Artifact
540
+
}
541
+
542
+
func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {
543
+
return p.executePlain("repo/fragments/artifact", w, params)
544
+
}
545
+
546
type RepoBlobParams struct {
547
+
LoggedInUser *oauth.User
548
+
RepoInfo repoinfo.RepoInfo
549
+
Active string
550
+
BreadCrumbs [][]string
551
+
ShowRendered bool
552
+
RenderToggle bool
553
+
RenderedContents template.HTML
554
types.RepoBlobResponse
555
}
556
557
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
558
+
var style *chroma.Style = styles.Get("catpuccin-latte")
559
+
560
+
if params.ShowRendered {
561
+
switch markup.GetFormat(params.Path) {
562
+
case markup.FormatMarkdown:
563
+
p.rctx.RepoInfo = params.RepoInfo
564
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
565
+
params.RenderedContents = template.HTML(bluemonday.UGCPolicy().Sanitize(p.rctx.RenderMarkdown(params.Contents)))
566
+
}
567
+
}
568
569
if params.Lines < 5000 {
570
c := params.Contents
571
formatter := chromahtml.New(
572
+
chromahtml.InlineCode(false),
573
chromahtml.WithLineNumbers(true),
574
chromahtml.WithLinkableLineNumbers(true, "L"),
575
chromahtml.Standalone(false),
576
+
chromahtml.WithClasses(true),
577
)
578
579
lexer := lexers.Get(filepath.Base(params.Path))
···
606
}
607
608
type RepoSettingsParams struct {
609
+
LoggedInUser *oauth.User
610
+
RepoInfo repoinfo.RepoInfo
611
Collaborators []Collaborator
612
Active string
613
+
Branches []string
614
+
DefaultBranch string
615
// TODO: use repoinfo.roles
616
IsCollaboratorInviteAllowed bool
617
}
···
622
}
623
624
type RepoIssuesParams struct {
625
+
LoggedInUser *oauth.User
626
+
RepoInfo repoinfo.RepoInfo
627
+
Active string
628
+
Issues []db.Issue
629
+
DidHandleMap map[string]string
630
+
Page pagination.Page
631
FilteringByOpen bool
632
}
633
···
637
}
638
639
type RepoSingleIssueParams struct {
640
+
LoggedInUser *oauth.User
641
+
RepoInfo repoinfo.RepoInfo
642
Active string
643
Issue db.Issue
644
Comments []db.Comment
···
659
}
660
661
type RepoNewIssueParams struct {
662
+
LoggedInUser *oauth.User
663
+
RepoInfo repoinfo.RepoInfo
664
Active string
665
}
666
···
669
return p.executeRepo("repo/issues/new", w, params)
670
}
671
672
+
type EditIssueCommentParams struct {
673
+
LoggedInUser *oauth.User
674
+
RepoInfo repoinfo.RepoInfo
675
+
Issue *db.Issue
676
+
Comment *db.Comment
677
+
}
678
+
679
+
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
680
+
return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
681
+
}
682
+
683
+
type SingleIssueCommentParams struct {
684
+
LoggedInUser *oauth.User
685
+
DidHandleMap map[string]string
686
+
RepoInfo repoinfo.RepoInfo
687
+
Issue *db.Issue
688
+
Comment *db.Comment
689
+
}
690
+
691
+
func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
692
+
return p.executePlain("repo/issues/fragments/issueComment", w, params)
693
+
}
694
+
695
type RepoNewPullParams struct {
696
+
LoggedInUser *oauth.User
697
+
RepoInfo repoinfo.RepoInfo
698
Branches []types.Branch
699
Active string
700
}
···
705
}
706
707
type RepoPullsParams struct {
708
+
LoggedInUser *oauth.User
709
+
RepoInfo repoinfo.RepoInfo
710
+
Pulls []*db.Pull
711
Active string
712
DidHandleMap map[string]string
713
FilteringBy db.PullState
···
718
return p.executeRepo("repo/pulls/pulls", w, params)
719
}
720
721
+
type ResubmitResult uint64
722
+
723
+
const (
724
+
ShouldResubmit ResubmitResult = iota
725
+
ShouldNotResubmit
726
+
Unknown
727
+
)
728
+
729
+
func (r ResubmitResult) Yes() bool {
730
+
return r == ShouldResubmit
731
+
}
732
+
func (r ResubmitResult) No() bool {
733
+
return r == ShouldNotResubmit
734
+
}
735
+
func (r ResubmitResult) Unknown() bool {
736
+
return r == Unknown
737
+
}
738
739
+
type RepoSinglePullParams struct {
740
+
LoggedInUser *oauth.User
741
+
RepoInfo repoinfo.RepoInfo
742
+
Active string
743
+
DidHandleMap map[string]string
744
+
Pull *db.Pull
745
+
MergeCheck types.MergeCheckResponse
746
+
ResubmitCheck ResubmitResult
747
}
748
749
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
752
}
753
754
type RepoPullPatchParams struct {
755
+
LoggedInUser *oauth.User
756
DidHandleMap map[string]string
757
+
RepoInfo repoinfo.RepoInfo
758
Pull *db.Pull
759
+
Diff *types.NiceDiff
760
Round int
761
Submission *db.PullSubmission
762
}
···
766
return p.execute("repo/pulls/patch", w, params)
767
}
768
769
+
type RepoPullInterdiffParams struct {
770
+
LoggedInUser *oauth.User
771
+
DidHandleMap map[string]string
772
+
RepoInfo repoinfo.RepoInfo
773
+
Pull *db.Pull
774
+
Round int
775
+
Interdiff *patchutil.InterdiffResult
776
+
}
777
+
778
+
// this name is a mouthful
779
+
func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error {
780
+
return p.execute("repo/pulls/interdiff", w, params)
781
+
}
782
+
783
+
type PullPatchUploadParams struct {
784
+
RepoInfo repoinfo.RepoInfo
785
+
}
786
+
787
+
func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error {
788
+
return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params)
789
+
}
790
+
791
+
type PullCompareBranchesParams struct {
792
+
RepoInfo repoinfo.RepoInfo
793
+
Branches []types.Branch
794
+
}
795
+
796
+
func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error {
797
+
return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params)
798
+
}
799
+
800
+
type PullCompareForkParams struct {
801
+
RepoInfo repoinfo.RepoInfo
802
+
Forks []db.Repo
803
+
}
804
+
805
+
func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error {
806
+
return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params)
807
+
}
808
+
809
+
type PullCompareForkBranchesParams struct {
810
+
RepoInfo repoinfo.RepoInfo
811
+
SourceBranches []types.Branch
812
+
TargetBranches []types.Branch
813
+
}
814
+
815
+
func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error {
816
+
return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params)
817
+
}
818
+
819
type PullResubmitParams struct {
820
+
LoggedInUser *oauth.User
821
+
RepoInfo repoinfo.RepoInfo
822
Pull *db.Pull
823
SubmissionId int
824
}
825
826
func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
827
+
return p.executePlain("repo/pulls/fragments/pullResubmit", w, params)
828
}
829
830
type PullActionsParams struct {
831
+
LoggedInUser *oauth.User
832
+
RepoInfo repoinfo.RepoInfo
833
+
Pull *db.Pull
834
+
RoundNumber int
835
+
MergeCheck types.MergeCheckResponse
836
+
ResubmitCheck ResubmitResult
837
}
838
839
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
840
+
return p.executePlain("repo/pulls/fragments/pullActions", w, params)
841
}
842
843
type PullNewCommentParams struct {
844
+
LoggedInUser *oauth.User
845
+
RepoInfo repoinfo.RepoInfo
846
Pull *db.Pull
847
RoundNumber int
848
}
849
850
func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
851
+
return p.executePlain("repo/pulls/fragments/pullNewComment", w, params)
852
}
853
854
func (p *Pages) Static() http.Handler {
855
+
if p.dev {
856
+
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
857
+
}
858
+
859
sub, err := fs.Sub(Files, "static")
860
if err != nil {
861
log.Fatalf("no static dir found? that's crazy: %v", err)
···
866
867
func Cache(h http.Handler) http.Handler {
868
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
869
+
path := strings.Split(r.URL.Path, "?")[0]
870
+
871
+
if strings.HasSuffix(path, ".css") {
872
// on day for css files
873
w.Header().Set("Cache-Control", "public, max-age=86400")
874
} else {
···
876
}
877
h.ServeHTTP(w, r)
878
})
879
+
}
880
+
881
+
func CssContentHash() string {
882
+
cssFile, err := Files.Open("static/tw.css")
883
+
if err != nil {
884
+
log.Printf("Error opening CSS file: %v", err)
885
+
return ""
886
+
}
887
+
defer cssFile.Close()
888
+
889
+
hasher := sha256.New()
890
+
if _, err := io.Copy(hasher, cssFile); err != nil {
891
+
log.Printf("Error hashing CSS file: %v", err)
892
+
return ""
893
+
}
894
+
895
+
return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
896
}
897
898
func (p *Pages) Error500(w io.Writer) error {
+117
appview/pages/repoinfo/repoinfo.go
+117
appview/pages/repoinfo/repoinfo.go
···
···
1
+
package repoinfo
2
+
3
+
import (
4
+
"fmt"
5
+
"path"
6
+
"slices"
7
+
"strings"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"tangled.sh/tangled.sh/core/appview/db"
11
+
"tangled.sh/tangled.sh/core/appview/state/userutil"
12
+
)
13
+
14
+
func (r RepoInfo) OwnerWithAt() string {
15
+
if r.OwnerHandle != "" {
16
+
return fmt.Sprintf("@%s", r.OwnerHandle)
17
+
} else {
18
+
return r.OwnerDid
19
+
}
20
+
}
21
+
22
+
func (r RepoInfo) FullName() string {
23
+
return path.Join(r.OwnerWithAt(), r.Name)
24
+
}
25
+
26
+
func (r RepoInfo) OwnerWithoutAt() string {
27
+
if strings.HasPrefix(r.OwnerWithAt(), "@") {
28
+
return strings.TrimPrefix(r.OwnerWithAt(), "@")
29
+
} else {
30
+
return userutil.FlattenDid(r.OwnerDid)
31
+
}
32
+
}
33
+
34
+
func (r RepoInfo) FullNameWithoutAt() string {
35
+
return path.Join(r.OwnerWithoutAt(), r.Name)
36
+
}
37
+
38
+
func (r RepoInfo) GetTabs() [][]string {
39
+
tabs := [][]string{
40
+
{"overview", "/", "square-chart-gantt"},
41
+
{"issues", "/issues", "circle-dot"},
42
+
{"pulls", "/pulls", "git-pull-request"},
43
+
}
44
+
45
+
if r.Roles.SettingsAllowed() {
46
+
tabs = append(tabs, []string{"settings", "/settings", "cog"})
47
+
}
48
+
49
+
return tabs
50
+
}
51
+
52
+
type RepoInfo struct {
53
+
Name string
54
+
OwnerDid string
55
+
OwnerHandle string
56
+
Description string
57
+
Knot string
58
+
RepoAt syntax.ATURI
59
+
IsStarred bool
60
+
Stats db.RepoStats
61
+
Roles RolesInRepo
62
+
Source *db.Repo
63
+
SourceHandle string
64
+
Ref string
65
+
DisableFork bool
66
+
}
67
+
68
+
// each tab on a repo could have some metadata:
69
+
//
70
+
// issues -> number of open issues etc.
71
+
// settings -> a warning icon to setup branch protection? idk
72
+
//
73
+
// we gather these bits of info here, because go templates
74
+
// are difficult to program in
75
+
func (r RepoInfo) TabMetadata() map[string]any {
76
+
meta := make(map[string]any)
77
+
78
+
if r.Stats.PullCount.Open > 0 {
79
+
meta["pulls"] = r.Stats.PullCount.Open
80
+
}
81
+
82
+
if r.Stats.IssueCount.Open > 0 {
83
+
meta["issues"] = r.Stats.IssueCount.Open
84
+
}
85
+
86
+
// more stuff?
87
+
88
+
return meta
89
+
}
90
+
91
+
type RolesInRepo struct {
92
+
Roles []string
93
+
}
94
+
95
+
func (r RolesInRepo) SettingsAllowed() bool {
96
+
return slices.Contains(r.Roles, "repo:settings")
97
+
}
98
+
99
+
func (r RolesInRepo) CollaboratorInviteAllowed() bool {
100
+
return slices.Contains(r.Roles, "repo:invite")
101
+
}
102
+
103
+
func (r RolesInRepo) RepoDeleteAllowed() bool {
104
+
return slices.Contains(r.Roles, "repo:delete")
105
+
}
106
+
107
+
func (r RolesInRepo) IsOwner() bool {
108
+
return slices.Contains(r.Roles, "repo:owner")
109
+
}
110
+
111
+
func (r RolesInRepo) IsCollaborator() bool {
112
+
return slices.Contains(r.Roles, "repo:collaborator")
113
+
}
114
+
115
+
func (r RolesInRepo) IsPushAllowed() bool {
116
+
return slices.Contains(r.Roles, "repo:push")
117
+
}
-112
appview/pages/templates/fragments/diff.html
-112
appview/pages/templates/fragments/diff.html
···
1
-
{{ define "fragments/diff" }}
2
-
{{ $repo := index . 0 }}
3
-
{{ $diff := index . 1 }}
4
-
{{ $commit := $diff.Commit }}
5
-
{{ $stat := $diff.Stat }}
6
-
{{ $diff := $diff.Diff }}
7
-
8
-
{{ $this := $commit.This }}
9
-
{{ $parent := $commit.Parent }}
10
-
11
-
{{ $last := sub (len $diff) 1 }}
12
-
{{ range $idx, $hunk := $diff }}
13
-
{{ with $hunk }}
14
-
<section class="mt-6 border border-gray-200 w-full mx-auto rounded bg-white drop-shadow-sm">
15
-
<div id="file-{{ .Name.New }}">
16
-
<div id="diff-file">
17
-
<details open>
18
-
<summary class="list-none cursor-pointer sticky top-0">
19
-
<div id="diff-file-header" class="rounded cursor-pointer bg-white flex justify-between">
20
-
<div id="left-side-items" class="p-2 flex gap-2 items-center">
21
-
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
22
-
23
-
{{ if .IsNew }}
24
-
<span class="bg-green-100 text-green-700 {{ $markerstyle }}">ADDED</span>
25
-
{{ else if .IsDelete }}
26
-
<span class="bg-red-100 text-red-700 {{ $markerstyle }}">DELETED</span>
27
-
{{ else if .IsCopy }}
28
-
<span class="bg-gray-100 text-gray-700 {{ $markerstyle }}">COPIED</span>
29
-
{{ else if .IsRename }}
30
-
<span class="bg-gray-100 text-gray-700 {{ $markerstyle }}">RENAMED</span>
31
-
{{ else }}
32
-
<span class="bg-gray-100 text-gray-700 {{ $markerstyle }}">MODIFIED</span>
33
-
{{ end }}
34
-
35
-
{{ if .IsDelete }}
36
-
<a {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
37
-
{{ .Name.Old }}
38
-
</a>
39
-
{{ else if (or .IsCopy .IsRename) }}
40
-
<a {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}>
41
-
{{ .Name.Old }}
42
-
</a>
43
-
{{ i "arrow-right" "w-4 h-4" }}
44
-
<a {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
45
-
{{ .Name.New }}
46
-
</a>
47
-
{{ else }}
48
-
<a {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
49
-
{{ .Name.New }}
50
-
</a>
51
-
{{ end }}
52
-
</div>
53
-
54
-
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 rounded" }}
55
-
<div id="right-side-items" class="p-2 flex items-center">
56
-
<a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
57
-
{{ if gt $idx 0 }}
58
-
{{ $prev := index $diff (sub $idx 1) }}
59
-
<a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
60
-
{{ end }}
61
-
62
-
{{ if lt $idx $last }}
63
-
{{ $next := index $diff (add $idx 1) }}
64
-
<a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
65
-
{{ end }}
66
-
</div>
67
-
68
-
</div>
69
-
</summary>
70
-
71
-
<div class="transition-all duration-700 ease-in-out">
72
-
{{ if .IsDelete }}
73
-
<p class="text-center text-gray-400 p-4">
74
-
This file has been deleted in this commit.
75
-
</p>
76
-
{{ else }}
77
-
{{ if .IsBinary }}
78
-
<p class="text-center text-gray-400 p-4">
79
-
This is a binary file and will not be displayed.
80
-
</p>
81
-
{{ else }}
82
-
<pre class="overflow-auto">
83
-
{{- range .TextFragments -}}
84
-
<div class="bg-gray-100 text-gray-500 select-none">{{ .Header }}</div>
85
-
{{- range .Lines -}}
86
-
{{- if eq .Op.String "+" -}}
87
-
<div class="bg-green-100 text-green-700 p-1 w-full min-w-fit"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div>
88
-
{{- end -}}
89
-
90
-
{{- if eq .Op.String "-" -}}
91
-
<div class="bg-red-100 text-red-700 p-1 w-full min-w-fit"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div>
92
-
{{- end -}}
93
-
94
-
{{- if eq .Op.String " " -}}
95
-
<div class="bg-white text-gray-500 px"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div>
96
-
{{- end -}}
97
-
98
-
{{- end -}}
99
-
{{- end -}}
100
-
</pre>
101
-
{{- end -}}
102
-
{{ end }}
103
-
</div>
104
-
105
-
</details>
106
-
107
-
</div>
108
-
</div>
109
-
</section>
110
-
{{ end }}
111
-
{{ end }}
112
-
{{ end }}
···
-11
appview/pages/templates/fragments/editRepoDescription.html
-11
appview/pages/templates/fragments/editRepoDescription.html
···
1
-
{{ define "fragments/editRepoDescription" }}
2
-
<form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2">
3
-
<input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}">
4
-
<button type="submit" class="btn p-2 flex items-center gap-2 no-underline text-sm">
5
-
{{ i "check" "w-3 h-3" }} save
6
-
</button>
7
-
<button type="button" class="btn p-2 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" >
8
-
{{ i "x" "w-3 h-3" }} cancel
9
-
</button>
10
-
</form>
11
-
{{ end }}
···
-17
appview/pages/templates/fragments/follow.html
-17
appview/pages/templates/fragments/follow.html
···
1
-
{{ define "fragments/follow" }}
2
-
<button id="followBtn"
3
-
class="btn mt-2 w-full"
4
-
5
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
-
hx-post="/follow?subject={{.UserDid}}"
7
-
{{ else }}
8
-
hx-delete="/follow?subject={{.UserDid}}"
9
-
{{ end }}
10
-
11
-
hx-trigger="click"
12
-
hx-target="#followBtn"
13
-
hx-swap="outerHTML"
14
-
>
15
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
16
-
</button>
17
-
{{ end }}
···
-72
appview/pages/templates/fragments/pullActions.html
-72
appview/pages/templates/fragments/pullActions.html
···
1
-
{{ define "fragments/pullActions" }}
2
-
{{ $lastIdx := sub (len .Pull.Submissions) 1 }}
3
-
{{ $roundNumber := .RoundNumber }}
4
-
5
-
{{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }}
6
-
{{ $isMerged := .Pull.State.IsMerged }}
7
-
{{ $isClosed := .Pull.State.IsClosed }}
8
-
{{ $isOpen := .Pull.State.IsOpen }}
9
-
{{ $isConflicted := and .MergeCheck (or .MergeCheck.Error .MergeCheck.IsConflicted) }}
10
-
{{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }}
11
-
{{ $isLastRound := eq $roundNumber $lastIdx }}
12
-
<div class="relative w-fit">
13
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
14
-
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
15
-
<button
16
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
17
-
hx-target="#actions-{{$roundNumber}}"
18
-
hx-swap="outerHtml"
19
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline">
20
-
{{ i "message-square-plus" "w-4 h-4" }}
21
-
<span>comment</span>
22
-
</button>
23
-
{{ if and $isPushAllowed $isOpen $isLastRound }}
24
-
{{ $disabled := "" }}
25
-
{{ if $isConflicted }}
26
-
{{ $disabled = "disabled" }}
27
-
{{ end }}
28
-
<button
29
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
30
-
hx-swap="none"
31
-
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
32
-
class="btn p-2 flex items-center gap-2" {{ $disabled }}>
33
-
{{ i "git-merge" "w-4 h-4" }}
34
-
<span>merge</span>
35
-
</button>
36
-
{{ end }}
37
-
38
-
{{ if and $isPullAuthor $isOpen $isLastRound }}
39
-
<button
40
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
41
-
hx-target="#actions-{{$roundNumber}}"
42
-
hx-swap="outerHtml"
43
-
class="btn p-2 flex items-center gap-2">
44
-
{{ i "rotate-ccw" "w-4 h-4" }}
45
-
<span>resubmit</span>
46
-
</button>
47
-
{{ end }}
48
-
49
-
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
50
-
<button
51
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
52
-
hx-swap="none"
53
-
class="btn p-2 flex items-center gap-2">
54
-
{{ i "ban" "w-4 h-4" }}
55
-
<span>close</span>
56
-
</button>
57
-
{{ end }}
58
-
59
-
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
60
-
<button
61
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
62
-
hx-swap="none"
63
-
class="btn p-2 flex items-center gap-2">
64
-
{{ i "circle-dot" "w-4 h-4" }}
65
-
<span>reopen</span>
66
-
</button>
67
-
{{ end }}
68
-
</div>
69
-
</div>
70
-
{{ end }}
71
-
72
-
···
-32
appview/pages/templates/fragments/pullNewComment.html
-32
appview/pages/templates/fragments/pullNewComment.html
···
1
-
{{ define "fragments/pullNewComment" }}
2
-
<div
3
-
id="pull-comment-card-{{ .RoundNumber }}"
4
-
class="bg-white rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
-
<div class="text-sm text-gray-500">
6
-
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
7
-
</div>
8
-
<form
9
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
10
-
hx-swap="none"
11
-
class="w-full flex flex-wrap gap-2">
12
-
<textarea
13
-
name="body"
14
-
class="w-full p-2 rounded border border-gray-200"
15
-
placeholder="Add to the discussion..."></textarea>
16
-
<button type="submit" class="btn flex items-center gap-2">
17
-
{{ i "message-square" "w-4 h-4" }} comment
18
-
</button>
19
-
<button
20
-
type="button"
21
-
class="btn flex items-center gap-2"
22
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions"
23
-
hx-swap="outerHTML"
24
-
hx-target="#pull-comment-card-{{ .RoundNumber }}">
25
-
{{ i "x" "w-4 h-4" }}
26
-
<span>cancel</span>
27
-
</button>
28
-
<div id="pull-comment"></div>
29
-
</form>
30
-
</div>
31
-
{{ end }}
32
-
···
-52
appview/pages/templates/fragments/pullResubmit.html
-52
appview/pages/templates/fragments/pullResubmit.html
···
1
-
{{ define "fragments/pullResubmit" }}
2
-
<div
3
-
id="resubmit-pull-card"
4
-
class="rounded relative border bg-amber-50 border-amber-200 px-6 py-2">
5
-
6
-
<div class="flex items-center gap-2 text-amber-500">
7
-
{{ i "pencil" "w-4 h-4" }}
8
-
<span class="font-medium">resubmit your patch</span>
9
-
</div>
10
-
11
-
<div class="mt-2 text-sm text-gray-700">
12
-
You can update this patch to address any reviews.
13
-
This will begin a new round of reviews,
14
-
but you'll still be able to view your previous submissions and feedback.
15
-
</div>
16
-
17
-
<div class="mt-4 flex flex-col">
18
-
<form
19
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
20
-
hx-swap="none"
21
-
class="w-full flex flex-wrap gap-2">
22
-
<textarea
23
-
name="patch"
24
-
class="w-full p-2 mb-2 rounded border border-gray-200"
25
-
placeholder="Paste your updated patch here."
26
-
rows="15"
27
-
>{{.Pull.LatestPatch}}</textarea>
28
-
<button
29
-
type="submit"
30
-
class="btn flex items-center gap-2"
31
-
{{ if or .Pull.State.IsClosed }}
32
-
disabled
33
-
{{ end }}>
34
-
{{ i "rotate-ccw" "w-4 h-4" }}
35
-
<span>resubmit</span>
36
-
</button>
37
-
<button
38
-
type="button"
39
-
class="btn flex items-center gap-2"
40
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions"
41
-
hx-swap="outerHTML"
42
-
hx-target="#resubmit-pull-card">
43
-
{{ i "x" "w-4 h-4" }}
44
-
<span>cancel</span>
45
-
</button>
46
-
</form>
47
-
48
-
<div id="resubmit-error" class="error"></div>
49
-
<div id="resubmit-success" class="success"></div>
50
-
</div>
51
-
</div>
52
-
{{ end }}
···
-15
appview/pages/templates/fragments/repoDescription.html
-15
appview/pages/templates/fragments/repoDescription.html
···
1
-
{{ define "fragments/repoDescription" }}
2
-
<span id="repo-description" class="flex flex-wrap items-center gap-2" hx-target="this" hx-swap="outerHTML">
3
-
{{ if .RepoInfo.Description }}
4
-
{{ .RepoInfo.Description }}
5
-
{{ else }}
6
-
<span class="italic">this repo has no description</span>
7
-
{{ end }}
8
-
9
-
{{ if .RepoInfo.Roles.IsOwner }}
10
-
<button class="btn p-2 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit">
11
-
{{ i "pencil" "w-3 h-3" }} edit
12
-
</button>
13
-
{{ end }}
14
-
</span>
15
-
{{ end }}
···
-28
appview/pages/templates/fragments/star.html
-28
appview/pages/templates/fragments/star.html
···
1
-
{{ define "fragments/star" }}
2
-
<button id="starBtn"
3
-
class="text-sm disabled:opacity-50 disabled:cursor-not-allowed"
4
-
5
-
{{ if .IsStarred }}
6
-
hx-delete="/star?subject={{.RepoAt}}&countHint={{.Stats.StarCount}}"
7
-
{{ else }}
8
-
hx-post="/star?subject={{.RepoAt}}&countHint={{.Stats.StarCount}}"
9
-
{{ end }}
10
-
11
-
hx-trigger="click"
12
-
hx-target="#starBtn"
13
-
hx-swap="outerHTML"
14
-
hx-disabled-elt="#starBtn"
15
-
>
16
-
<div class="flex gap-2 items-center">
17
-
{{ if .IsStarred }}
18
-
{{ i "star" "w-3 h-3 fill-current" }}
19
-
{{ else }}
20
-
{{ i "star" "w-3 h-3" }}
21
-
{{ end }}
22
-
<span>
23
-
{{ .Stats.StarCount }}
24
-
</span>
25
-
</div>
26
-
</button>
27
-
{{ end }}
28
-
···
+92
-34
appview/pages/templates/knot.html
+92
-34
appview/pages/templates/knot.html
···
1
-
{{define "title"}}{{ .Registration.Domain }}{{end}}
2
3
-
{{define "content"}}
4
-
<h1>{{.Registration.Domain}}</h1>
5
-
<p>
6
-
<code>
7
-
opened by: {{.Registration.ByDid}}
8
-
{{ if eq $.LoggedInUser.Did $.Registration.ByDid }}
9
-
(you)
10
-
{{ end }}
11
-
</code><br>
12
-
<code>on: {{.Registration.Created}}</code><br>
13
-
{{ if .Registration.Registered }}
14
-
<code>registered on: {{.Registration.Registered}}</code>
15
-
{{ else }}
16
-
<code>pending registration</code>
17
-
<button class="btn my-2" hx-post="/knots/{{.Domain}}/init" hx-swap="none">initialize</button>
18
{{ end }}
19
-
</p>
20
-
21
{{ if .Registration.Registered }}
22
-
<h3> members </h3>
23
-
<ol>
24
-
{{ range $.Members }}
25
-
<li><a href="/{{.}}">{{.}}</a></li>
26
{{ else }}
27
-
<p>no members</p>
28
{{ end }}
29
-
{{ end }}
30
-
</ol>
31
32
-
{{ if $.IsOwner }}
33
-
<h3>add member</h3>
34
-
<form hx-put="/knots/{{.Registration.Domain}}/member">
35
-
<label for="member">did or handle:</label>
36
-
<input type="text" id="member" name="member" required>
37
-
<button class="btn my-2" type="text">add member</button>
38
-
</form>
39
-
{{ end }}
40
-
{{end}}
···
1
+
{{ define "title" }}{{ .Registration.Domain }}{{ end }}
2
3
+
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</p>
6
+
</div>
7
+
8
+
<div class="flex flex-col">
9
+
{{ block "registration-info" . }} {{ end }}
10
+
{{ block "members" . }} {{ end }}
11
+
{{ block "add-member" . }} {{ end }}
12
+
</div>
13
+
{{ end }}
14
+
15
+
{{ define "registration-info" }}
16
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
17
+
<dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200">
18
+
<dt class="font-bold">opened by</dt>
19
+
<dd>
20
+
<span>
21
+
{{ index $.DidHandleMap .Registration.ByDid }} <span class="text-gray-500 dark:text-gray-400 font-mono">{{ .Registration.ByDid }}</span>
22
+
</span>
23
+
{{ if eq $.LoggedInUser.Did $.Registration.ByDid }}
24
+
<span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded ml-2">you</span>
25
{{ end }}
26
+
</dd>
27
+
28
+
<dt class="font-bold">opened</dt>
29
+
<dd>{{ .Registration.Created | timeFmt }}</dd>
30
+
31
{{ if .Registration.Registered }}
32
+
<dt class="font-bold">registered</dt>
33
+
<dd>{{ .Registration.Registered | timeFmt }}</dd>
34
{{ else }}
35
+
<dt class="font-bold">status</dt>
36
+
<dd class="text-yellow-800 dark:text-yellow-200 bg-yellow-100 dark:bg-yellow-900 rounded px-2 py-1 inline-block">
37
+
Pending Registration
38
+
</dd>
39
{{ end }}
40
+
</dl>
41
+
42
+
{{ if not .Registration.Registered }}
43
+
<div class="mt-4">
44
+
<button
45
+
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
46
+
hx-post="/knots/{{.Domain}}/init"
47
+
hx-swap="none">
48
+
Initialize Registration
49
+
</button>
50
+
</div>
51
+
{{ end }}
52
+
</section>
53
+
{{ end }}
54
+
55
+
{{ define "members" }}
56
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">members</h2>
57
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
58
+
{{ if .Registration.Registered }}
59
+
<div id="member-list" class="flex flex-col gap-4">
60
+
{{ range $.Members }}
61
+
<div class="inline-flex items-center gap-4">
62
+
{{ i "user" "w-4 h-4 dark:text-gray-300" }}
63
+
<a href="/{{index $.DidHandleMap .}}" class="text-gray-900 dark:text-white">{{index $.DidHandleMap .}}
64
+
<span class="text-gray-500 dark:text-gray-400 font-mono">{{.}}</span>
65
+
</a>
66
+
</div>
67
+
{{ else }}
68
+
<p class="text-gray-500 dark:text-gray-400">No members have been added yet.</p>
69
+
{{ end }}
70
+
</div>
71
+
{{ else }}
72
+
<p class="text-gray-500 dark:text-gray-400">Members can be added after registration is complete.</p>
73
+
{{ end }}
74
+
</section>
75
+
{{ end }}
76
77
+
{{ define "add-member" }}
78
+
{{ if $.IsOwner }}
79
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">add member</h2>
80
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
81
+
<form
82
+
hx-put="/knots/{{.Registration.Domain}}/member"
83
+
class="max-w-2xl space-y-4">
84
+
<input
85
+
type="text"
86
+
id="subject"
87
+
name="subject"
88
+
placeholder="did or handle"
89
+
required
90
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
91
+
92
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add member</button>
93
+
94
+
<div id="add-member-error" class="error dark:text-red-400"></div>
95
+
</form>
96
+
</section>
97
+
{{ end }}
98
+
{{ end }}
+79
-84
appview/pages/templates/knots.html
+79
-84
appview/pages/templates/knots.html
···
1
{{ define "title" }}knots{{ end }}
2
-
3
{{ define "content" }}
4
-
<h1>knots</h1>
5
6
-
<section class="mb-12">
7
-
<h2 class="text-2xl mb-4">register a knot</h2>
8
-
<form hx-post="/knots/key" class="flex gap-4 items-end">
9
-
<div>
10
-
<label for="domain"
11
-
>Generate a key to start your knot with.</label
12
-
>
13
-
<input
14
-
type="text"
15
-
id="domain"
16
-
name="domain"
17
-
placeholder="knot.example.com"
18
-
required
19
-
/>
20
</div>
21
-
<button class="btn" type="submit">generate key</button>
22
-
</form>
23
</section>
24
25
-
<section class="mb-12">
26
-
<h3 class="text-xl font-semibold mb-4">my knots</h3>
27
-
<p>This is a list of knots</p>
28
-
<ul id="my-knots" class="space-y-6">
29
-
{{ range .Registrations }}
30
-
{{ if .Registered }}
31
-
<li class="border rounded p-4 flex flex-col gap-2">
32
-
<div>
33
-
<a href="/knots/{{ .Domain }}" class="font-semibold"
34
-
>{{ .Domain }}</a
35
-
>
36
-
</div>
37
-
<div class="text-gray-600">
38
-
Owned by
39
-
{{ .ByDid }}
40
-
</div>
41
-
<div class="text-gray-600">
42
-
Registered on
43
-
{{ .Registered }}
44
-
</div>
45
-
</li>
46
-
{{ end }}
47
-
{{ else }}
48
-
<p class="text-gray-600">you don't have any knots yet</p>
49
-
{{ end }}
50
-
</ul>
51
</section>
52
-
53
-
<section>
54
-
<h3 class="text-xl font-semibold mb-4">pending registrations</h3>
55
-
<ul id="pending-registrations" class="space-y-6">
56
-
{{ range .Registrations }}
57
-
{{ if not .Registered }}
58
-
<li class="border rounded p-4 flex flex-col gap-2">
59
-
<div>
60
-
<a
61
-
href="/knots/{{ .Domain }}"
62
-
class="text-blue-600 hover:underline"
63
-
>{{ .Domain }}</a
64
-
>
65
-
</div>
66
-
<div class="text-gray-600">
67
-
Opened by
68
-
{{ .ByDid }}
69
-
</div>
70
-
<div class="text-gray-600">
71
-
Created on
72
-
{{ .Created }}
73
-
</div>
74
-
<div class="flex items-center gap-4 mt-2">
75
-
<span class="text-amber-600"
76
-
>pending registration</span
77
-
>
78
-
<button
79
-
class="btn"
80
-
hx-post="/knots/{{ .Domain }}/init"
81
-
>
82
-
initialize
83
-
</button>
84
-
</div>
85
-
</li>
86
-
{{ end }}
87
-
{{ else }}
88
-
<p class="text-gray-600">no registrations yet</p>
89
-
{{ end }}
90
-
</ul>
91
-
</section>
92
{{ end }}
···
1
{{ define "title" }}knots{{ end }}
2
{{ define "content" }}
3
+
<div class="p-6">
4
+
<p class="text-xl font-bold dark:text-white">Knots</p>
5
+
</div>
6
+
<div class="flex flex-col">
7
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">register a knot</h2>
8
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
9
+
<p class="mb-8 dark:text-gray-300">Generate a key to initialize your knot server.</p>
10
+
<form
11
+
hx-post="/knots/key"
12
+
class="max-w-2xl mb-8 space-y-4"
13
+
>
14
+
<input
15
+
type="text"
16
+
id="domain"
17
+
name="domain"
18
+
placeholder="knot.example.com"
19
+
required
20
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
21
+
/>
22
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">
23
+
generate key
24
+
</button>
25
+
<div id="settings-knots-error" class="error dark:text-red-400"></div>
26
+
</form>
27
+
</section>
28
29
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">my knots</h2>
30
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
31
+
<div id="knots-list" class="flex flex-col gap-6 mb-8">
32
+
{{ range .Registrations }}
33
+
{{ if .Registered }}
34
+
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
35
+
<div class="flex flex-col gap-1">
36
+
<div class="inline-flex items-center gap-4">
37
+
{{ i "git-branch" "w-3 h-3 dark:text-gray-300" }}
38
+
<a href="/knots/{{ .Domain }}">
39
+
<p class="font-bold dark:text-white">{{ .Domain }}</p>
40
+
</a>
41
+
</div>
42
+
<p class="text-sm text-gray-500 dark:text-gray-400">owned by {{ .ByDid }}</p>
43
+
<p class="text-sm text-gray-500 dark:text-gray-400">registered {{ .Registered | timeFmt }}</p>
44
</div>
45
+
</div>
46
+
{{ end }}
47
+
{{ else }}
48
+
<p class="text-sm text-gray-500 dark:text-gray-400">No knots registered</p>
49
+
{{ end }}
50
+
</div>
51
</section>
52
53
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">pending registrations</h2>
54
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
55
+
<div id="pending-knots-list" class="flex flex-col gap-6 mb-8">
56
+
{{ range .Registrations }}
57
+
{{ if not .Registered }}
58
+
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
59
+
<div class="flex flex-col gap-1">
60
+
<div class="inline-flex items-center gap-4">
61
+
<p class="font-bold dark:text-white">{{ .Domain }}</p>
62
+
<div class="inline-flex items-center gap-1">
63
+
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">
64
+
pending
65
+
</span>
66
+
</div>
67
+
</div>
68
+
<p class="text-sm text-gray-500 dark:text-gray-400">opened by {{ .ByDid }}</p>
69
+
<p class="text-sm text-gray-500 dark:text-gray-400">created {{ .Created | timeFmt }}</p>
70
+
</div>
71
+
<div class="flex gap-2 items-center">
72
+
<button
73
+
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 gap-2"
74
+
hx-post="/knots/{{ .Domain }}/init">
75
+
{{ i "square-play" "w-5 h-5" }}
76
+
<span class="hidden md:inline">initialize</span>
77
+
</button>
78
+
</div>
79
+
</div>
80
+
{{ end }}
81
+
{{ else }}
82
+
<p class="text-sm text-gray-500 dark:text-gray-400">No pending registrations</p>
83
+
{{ end }}
84
+
</div>
85
</section>
86
+
</div>
87
{{ end }}
+6
-6
appview/pages/templates/layouts/base.html
+6
-6
appview/pages/templates/layouts/base.html
···
1
{{ define "layouts/base" }}
2
<!doctype html>
3
-
<html lang="en">
4
<head>
5
<meta charset="UTF-8" />
6
<meta
7
name="viewport"
8
content="width=device-width, initial-scale=1.0"
9
/>
10
<script src="/static/htmx.min.js"></script>
11
-
<link href="/static/tw.css" rel="stylesheet" type="text/css" />
12
-
13
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
14
{{ block "extrameta" . }}{{ end }}
15
</head>
16
-
<body class="bg-slate-100">
17
-
<div class="container mx-auto px-1 pt-4 min-h-screen flex flex-col">
18
-
<header style="z-index: 5">
19
{{ block "topbar" . }}
20
{{ template "layouts/topbar" . }}
21
{{ end }}
···
1
{{ define "layouts/base" }}
2
<!doctype 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 name="htmx-config" content='{"includeIndicatorStyles": false}'>
11
<script src="/static/htmx.min.js"></script>
12
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
14
{{ block "extrameta" . }}{{ end }}
15
</head>
16
+
<body class="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
17
+
<div class="container mx-auto px-1 md:pt-4 min-h-screen flex flex-col">
18
+
<header style="z-index: 20">
19
{{ block "topbar" . }}
20
{{ template "layouts/topbar" . }}
21
{{ end }}
+34
-19
appview/pages/templates/layouts/repobase.html
+34
-19
appview/pages/templates/layouts/repobase.html
···
1
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
3
{{ define "content" }}
4
-
<section id="repo-header" class="mb-4 py-2 px-6">
5
-
<p class="text-lg">
6
-
<a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a>
7
-
<span class="select-none">/</span>
8
-
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
9
-
<span class="ml-3">
10
-
{{ template "fragments/star" .RepoInfo }}
11
-
</span>
12
-
</p>
13
-
{{ template "fragments/repoDescription" . }}
14
-
</section>
15
<section class="min-h-screen flex flex-col drop-shadow-sm">
16
<nav class="w-full pl-4 overflow-auto">
17
<div class="flex z-60">
18
-
{{ $activeTabStyles := "-mb-px bg-white" }}
19
{{ $tabs := .RepoInfo.GetTabs }}
20
{{ $tabmeta := .RepoInfo.TabMetadata }}
21
{{ range $item := $tabs }}
22
{{ $key := index $item 0 }}
23
{{ $value := index $item 1 }}
24
{{ $meta := index $tabmeta $key }}
25
<a
26
href="/{{ $.RepoInfo.FullName }}{{ $value }}"
···
28
hx-boost="true"
29
>
30
<div
31
-
class="px-4 py-1 mr-1 text-black min-w-[80px] text-center relative rounded-t whitespace-nowrap
32
{{ if eq $.Active $key }}
33
{{ $activeTabStyles }}
34
{{ else }}
35
-
group-hover:bg-gray-200
36
{{ end }}
37
"
38
>
39
-
{{ $key }}
40
-
{{ if not (isNil $meta) }}
41
-
<span class="bg-gray-200 rounded py-1/2 px-1 text-sm">{{ $meta }}</span>
42
-
{{ end }}
43
</div>
44
</a>
45
{{ end }}
46
</div>
47
</nav>
48
<section
49
-
class="bg-white p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm"
50
>
51
{{ block "repoContent" . }}{{ end }}
52
</section>
···
1
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
3
{{ define "content" }}
4
+
<section id="repo-header" class="mb-4 py-2 px-6 dark:text-white">
5
+
{{ if .RepoInfo.Source }}
6
+
<p class="text-sm">
7
+
<div class="flex items-center">
8
+
{{ i "git-fork" "w-3 h-3 mr-1"}}
9
+
forked from
10
+
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
11
+
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
12
+
</div>
13
+
</p>
14
+
{{ end }}
15
+
<div class="text-lg flex items-center justify-between">
16
+
<div>
17
+
<a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a>
18
+
<span class="select-none">/</span>
19
+
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
20
+
</div>
21
+
22
+
{{ template "repo/fragments/repoActions" .RepoInfo }}
23
+
</div>
24
+
{{ template "repo/fragments/repoDescription" . }}
25
+
</section>
26
<section class="min-h-screen flex flex-col drop-shadow-sm">
27
<nav class="w-full pl-4 overflow-auto">
28
<div class="flex z-60">
29
+
{{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }}
30
{{ $tabs := .RepoInfo.GetTabs }}
31
{{ $tabmeta := .RepoInfo.TabMetadata }}
32
{{ range $item := $tabs }}
33
{{ $key := index $item 0 }}
34
{{ $value := index $item 1 }}
35
+
{{ $icon := index $item 2 }}
36
{{ $meta := index $tabmeta $key }}
37
<a
38
href="/{{ $.RepoInfo.FullName }}{{ $value }}"
···
40
hx-boost="true"
41
>
42
<div
43
+
class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap
44
{{ if eq $.Active $key }}
45
{{ $activeTabStyles }}
46
{{ else }}
47
+
group-hover:bg-gray-200 dark:group-hover:bg-gray-700
48
{{ end }}
49
"
50
>
51
+
<span class="flex items-center justify-center">
52
+
{{ i $icon "w-4 h-4 mr-2" }}
53
+
{{ $key }}
54
+
{{ if not (isNil $meta) }}
55
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
56
+
{{ end }}
57
+
</span>
58
</div>
59
</a>
60
{{ end }}
61
</div>
62
</nav>
63
<section
64
+
class="bg-white dark:bg-gray-800 p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm dark:text-white"
65
>
66
{{ block "repoContent" . }}{{ end }}
67
</section>
+8
-3
appview/pages/templates/layouts/topbar.html
+8
-3
appview/pages/templates/layouts/topbar.html
···
1
{{ define "layouts/topbar" }}
2
-
<nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white drop-shadow-sm">
3
<div class="container flex justify-between p-0">
4
<div id="left-items">
5
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
···
28
{{ didOrHandle .Did .Handle }}
29
</summary>
30
<div
31
-
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white border border-gray-200"
32
>
33
<a href="/{{ didOrHandle .Did .Handle }}">profile</a>
34
<a href="/knots">knots</a>
35
<a href="/settings">settings</a>
36
-
<a href="/logout" class="text-red-400 hover:text-red-700">logout</a>
37
</div>
38
</details>
39
{{ end }}
···
1
{{ define "layouts/topbar" }}
2
+
<nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
<div class="container flex justify-between p-0">
4
<div id="left-items">
5
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
···
28
{{ didOrHandle .Did .Handle }}
29
</summary>
30
<div
31
+
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
>
33
<a href="/{{ didOrHandle .Did .Handle }}">profile</a>
34
<a href="/knots">knots</a>
35
<a href="/settings">settings</a>
36
+
<a href="#"
37
+
hx-post="/logout"
38
+
hx-swap="none"
39
+
class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
40
+
logout
41
+
</a>
42
</div>
43
</details>
44
{{ end }}
+27
-23
appview/pages/templates/repo/blob.html
+27
-23
appview/pages/templates/repo/blob.html
···
15
{{ $lines := split .Contents }}
16
{{ $tot_lines := len $lines }}
17
{{ $tot_chars := len (printf "%d" $tot_lines) }}
18
-
{{ $code_number_style := "text-gray-400 left-0 bg-white text-right mr-6 select-none inline-block w-12" }}
19
{{ $linkstyle := "no-underline hover:underline" }}
20
-
<div class="pb-2 text-base">
21
-
<div class="flex justify-between">
22
-
<div id="breadcrumbs">
23
{{ range $idx, $value := .BreadCrumbs }}
24
{{ if ne $idx (sub (len $.BreadCrumbs) 1) }}
25
<a
26
href="{{ index . 1 }}"
27
-
class="text-bold text-gray-500 {{ $linkstyle }}"
28
>{{ index . 0 }}</a
29
>
30
/
31
{{ else }}
32
-
<span class="text-bold text-gray-500"
33
>{{ index . 0 }}</span
34
>
35
{{ end }}
36
{{ end }}
37
</div>
38
-
<div id="file-info" class="text-gray-500 text-xs">
39
-
{{ .Lines }} lines
40
-
<span class="select-none px-2 [&:before]:content-['ยท']"></span>
41
-
{{ byteFmt .SizeHint }}
42
</div>
43
</div>
44
</div>
45
{{ if .IsBinary }}
46
-
<p class="text-center text-gray-400">
47
This is a binary file and will not be displayed.
48
</p>
49
{{ else }}
50
-
<div class="overflow-auto relative text-ellipsis">
51
-
{{ range $idx, $line := $lines }}
52
-
{{ $linenr := add $idx 1 }}
53
-
<div class="flex">
54
-
<a href="#L{{ $linenr }}" id="L{{ $linenr }}" class="no-underline peer">
55
-
<span class="{{ $code_number_style }}"
56
-
style="min-width: {{ $tot_chars }}ch;">
57
-
{{ $linenr }}
58
-
</span>
59
-
</a>
60
-
<div class="whitespace-pre peer-target:bg-yellow-200">{{ $line | escapeHtml }}</div>
61
-
</div>
62
{{ end }}
63
</div>
64
{{ end }}
···
15
{{ $lines := split .Contents }}
16
{{ $tot_lines := len $lines }}
17
{{ $tot_chars := len (printf "%d" $tot_lines) }}
18
+
{{ $code_number_style := "text-gray-400 dark:text-gray-500 left-0 bg-white dark:bg-gray-800 text-right mr-6 select-none inline-block w-12" }}
19
{{ $linkstyle := "no-underline hover:underline" }}
20
+
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
21
+
<div class="flex flex-col md:flex-row md:justify-between gap-2">
22
+
<div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500">
23
{{ range $idx, $value := .BreadCrumbs }}
24
{{ if ne $idx (sub (len $.BreadCrumbs) 1) }}
25
<a
26
href="{{ index . 1 }}"
27
+
class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}"
28
>{{ index . 0 }}</a
29
>
30
/
31
{{ else }}
32
+
<span class="text-bold text-black dark:text-white"
33
>{{ index . 0 }}</span
34
>
35
{{ end }}
36
{{ end }}
37
</div>
38
+
<div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
39
+
<span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span>
40
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
41
+
<span>{{ .Lines }} lines</span>
42
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
43
+
<span>{{ byteFmt .SizeHint }}</span>
44
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
45
+
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
46
+
{{ if .RenderToggle }}
47
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
48
+
<a
49
+
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
50
+
hx-boost="true"
51
+
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
52
+
{{ end }}
53
</div>
54
</div>
55
</div>
56
{{ if .IsBinary }}
57
+
<p class="text-center text-gray-400 dark:text-gray-500">
58
This is a binary file and will not be displayed.
59
</p>
60
{{ else }}
61
+
<div class="overflow-auto relative">
62
+
{{ if .ShowRendered }}
63
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
64
+
{{ else }}
65
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div>
66
{{ end }}
67
</div>
68
{{ end }}
+95
-10
appview/pages/templates/repo/branches.html
+95
-10
appview/pages/templates/repo/branches.html
···
1
{{ define "title" }}
2
-
branches | {{ .RepoInfo.FullName }}
3
{{ end }}
4
5
{{ define "repoContent" }}
6
-
<h3>branches</h3>
7
-
<div class="refs">
8
-
{{ range .Branches }}
9
-
<div>
10
-
<strong>{{ .Name }}</strong>
11
-
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name }}/">browse</a>
12
-
<a href="/{{ $.RepoInfo.FullName }}/log/{{ .Name }}">log</a>
13
-
</div>
14
-
{{ end }}
15
</div>
16
{{ end }}
···
1
{{ define "title" }}
2
+
branches ยท {{ .RepoInfo.FullName }}
3
{{ end }}
4
5
{{ define "repoContent" }}
6
+
<section id="branches-table" class="overflow-x-auto">
7
+
<h2 class="font-bold text-sm mb-4 uppercase dark:text-white">
8
+
Branches
9
+
</h2>
10
+
11
+
<!-- desktop view (hidden on small screens) -->
12
+
<table class="w-full border-collapse hidden md:table">
13
+
<thead>
14
+
<tr>
15
+
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Name</th>
16
+
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Commit</th>
17
+
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Message</th>
18
+
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Date</th>
19
+
</tr>
20
+
</thead>
21
+
<tbody>
22
+
{{ range $index, $branch := .Branches }}
23
+
<tr class="{{ if ne $index (sub (len $.Branches) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}">
24
+
<td class="py-3 whitespace-nowrap">
25
+
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2">
26
+
<span class="dark:text-white">
27
+
{{ .Name }}
28
+
</span>
29
+
{{ if .IsDefault }}
30
+
<span class="
31
+
text-sm rounded
32
+
bg-gray-100 dark:bg-gray-700 text-black dark:text-white
33
+
font-mono
34
+
px-2 mx-1/2
35
+
inline-flex items-center
36
+
">
37
+
default
38
+
</span>
39
+
{{ end }}
40
+
</a>
41
+
</td>
42
+
<td class="py-3 whitespace-nowrap">
43
+
{{ if .Commit }}
44
+
<a href="/{{ $.RepoInfo.FullName }}/commits/{{ .Name | urlquery }}" class="font-mono text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ slice .Commit.Hash.String 0 8 }}</a>
45
+
{{ end }}
46
+
</td>
47
+
<td class="py-3 whitespace-nowrap">
48
+
{{ if .Commit }}
49
+
{{ $messageParts := splitN .Commit.Message "\n\n" 2 }}
50
+
<span class="text-gray-700 dark:text-gray-300">{{ index $messageParts 0 }}</span>
51
+
{{ end }}
52
+
</td>
53
+
<td class="py-3 whitespace-nowrap text-gray-500 dark:text-gray-400">
54
+
{{ if .Commit }}
55
+
{{ .Commit.Author.When | timeFmt }}
56
+
{{ end }}
57
+
</td>
58
+
</tr>
59
+
{{ end }}
60
+
</tbody>
61
+
</table>
62
+
63
+
<!-- mobile view (visible only on small screens) -->
64
+
<div class="md:hidden">
65
+
{{ range $index, $branch := .Branches }}
66
+
<div class="relative p-2 {{ if ne $index (sub (len $.Branches) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}">
67
+
<div class="flex items-center justify-between">
68
+
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2">
69
+
<span class="dark:text-white font-medium">
70
+
{{ .Name }}
71
+
</span>
72
+
{{ if .IsDefault }}
73
+
<span class="
74
+
text-xs rounded
75
+
bg-gray-100 dark:bg-gray-700 text-black dark:text-white
76
+
font-mono
77
+
px-2
78
+
inline-flex items-center
79
+
">
80
+
default
81
+
</span>
82
+
{{ end }}
83
+
</a>
84
+
</div>
85
+
86
+
{{ if .Commit }}
87
+
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center">
88
+
<span class="font-mono">
89
+
<a href="/{{ $.RepoInfo.FullName }}/commits/{{ .Name | urlquery }}" class="text-gray-500 dark:text-gray-400 no-underline hover:underline">
90
+
{{ slice .Commit.Hash.String 0 8 }}
91
+
</a>
92
+
</span>
93
+
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
94
+
<span>{{ .Commit.Author.When | timeFmt }}</span>
95
+
</div>
96
+
{{ end }}
97
</div>
98
+
{{ end }}
99
+
</div>
100
+
</section>
101
{{ end }}
+10
-27
appview/pages/templates/repo/commit.html
+10
-27
appview/pages/templates/repo/commit.html
···
4
5
{{ $repo := .RepoInfo.FullName }}
6
{{ $commit := .Diff.Commit }}
7
-
{{ $stat := .Diff.Stat }}
8
-
{{ $diff := .Diff.Diff }}
9
10
-
<section class="commit">
11
<div id="commit-message">
12
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
13
<div>
14
<p class="pb-2">{{ index $messageParts 0 }}</p>
15
{{ if gt (len $messageParts) 1 }}
16
-
<p class="mt-1 cursor-text pb-2 text-sm">{{ nl2br (unwrapText (index $messageParts 1)) }}</p>
17
{{ end }}
18
</div>
19
</div>
20
21
<div class="flex items-center">
22
-
<p class="text-sm text-gray-500">
23
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
24
25
{{ if $didOrHandle }}
26
-
<a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500">{{ $didOrHandle }}</a>
27
{{ else }}
28
-
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500">{{ $commit.Author.Name }}</a>
29
{{ end }}
30
<span class="px-1 select-none before:content-['\00B7']"></span>
31
{{ timeFmt $commit.Author.When }}
32
<span class="px-1 select-none before:content-['\00B7']"></span>
33
-
<span>{{ $stat.FilesChanged }}</span> files <span class="font-mono">(+{{ $stat.Insertions }}, -{{ $stat.Deletions }})</span>
34
-
<span class="px-1 select-none before:content-['\00B7']"></span>
35
</p>
36
37
-
<p class="flex items-center text-sm text-gray-500">
38
-
<a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500">{{ slice $commit.This 0 8 }}</a>
39
{{ if $commit.Parent }}
40
{{ i "arrow-left" "w-3 h-3 mx-1" }}
41
-
<a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500">{{ slice $commit.Parent 0 8 }}</a>
42
{{ end }}
43
</p>
44
</div>
45
-
46
-
<div class="diff-stat">
47
-
<br>
48
-
<strong class="text-sm uppercase mb-4">Changed files</strong>
49
-
{{ range $diff }}
50
-
<ul>
51
-
{{ if .IsDelete }}
52
-
<li><a href="#file-{{ .Name.Old }}">{{ .Name.Old }}</a></li>
53
-
{{ else }}
54
-
<li><a href="#file-{{ .Name.New }}">{{ .Name.New }}</a></li>
55
-
{{ end }}
56
-
</ul>
57
-
{{ end }}
58
-
</div>
59
</section>
60
61
{{end}}
62
63
{{ define "repoAfter" }}
64
-
{{ template "fragments/diff" (list .RepoInfo.FullName .Diff) }}
65
{{end}}
···
4
5
{{ $repo := .RepoInfo.FullName }}
6
{{ $commit := .Diff.Commit }}
7
8
+
<section class="commit dark:text-white">
9
<div id="commit-message">
10
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
11
<div>
12
<p class="pb-2">{{ index $messageParts 0 }}</p>
13
{{ if gt (len $messageParts) 1 }}
14
+
<p class="mt-1 cursor-text pb-2 text-sm">{{ nl2br (index $messageParts 1) }}</p>
15
{{ end }}
16
</div>
17
</div>
18
19
<div class="flex items-center">
20
+
<p class="text-sm text-gray-500 dark:text-gray-300">
21
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
22
23
{{ if $didOrHandle }}
24
+
<a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a>
25
{{ else }}
26
+
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a>
27
{{ end }}
28
<span class="px-1 select-none before:content-['\00B7']"></span>
29
{{ timeFmt $commit.Author.When }}
30
<span class="px-1 select-none before:content-['\00B7']"></span>
31
</p>
32
33
+
<p class="flex items-center text-sm text-gray-500 dark:text-gray-300">
34
+
<a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a>
35
{{ if $commit.Parent }}
36
{{ i "arrow-left" "w-3 h-3 mx-1" }}
37
+
<a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a>
38
{{ end }}
39
</p>
40
</div>
41
+
42
</section>
43
44
{{end}}
45
46
{{ define "repoAfter" }}
47
+
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
48
{{end}}
+2
-21
appview/pages/templates/repo/empty.html
+2
-21
appview/pages/templates/repo/empty.html
···
2
3
{{ define "repoContent" }}
4
<main>
5
-
<p class="text-center pt-5 text-gray-400">
6
This is an empty repository. Push some commits here.
7
</p>
8
</main>
9
{{ end }}
10
11
{{ define "repoAfter" }}
12
-
<section class="mt-4 p-6 rounded bg-white w-full mx-auto overflow-auto">
13
-
<strong>push</strong>
14
-
<div class="py-2">
15
-
<code>git remote add origin git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
16
-
</div>
17
-
<strong>clone</strong>
18
-
19
-
20
-
<div class="flex flex-col gap-2">
21
-
<div class="pt-2 flex flex-row gap-2">
22
-
<span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">HTTP</span>
23
-
<code>git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
24
-
</div>
25
-
<div class="pt-2 flex flex-row gap-2">
26
-
<span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">SSH</span><code>git clone git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
27
-
</div>
28
-
</div>
29
-
<p class="py-2 text-gray-500">Note that for self-hosted knots, clone URLs may be different based on your setup.</p>
30
-
</section>
31
-
32
{{ end }}
+38
appview/pages/templates/repo/fork.html
+38
appview/pages/templates/repo/fork.html
···
···
1
+
{{ define "title" }}fork · {{ .RepoInfo.FullName }}{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p>
6
+
</div>
7
+
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
+
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none">
9
+
<fieldset class="space-y-3">
10
+
<legend class="dark:text-white">Select a knot to fork into</legend>
11
+
<div class="space-y-2">
12
+
<div class="flex flex-col">
13
+
{{ range .Knots }}
14
+
<div class="flex items-center">
15
+
<input
16
+
type="radio"
17
+
name="knot"
18
+
value="{{ . }}"
19
+
class="mr-2"
20
+
id="domain-{{ . }}"
21
+
/>
22
+
<span class="dark:text-white">{{ . }}</span>
23
+
</div>
24
+
{{ else }}
25
+
<p class="dark:text-white">No knots available.</p>
26
+
{{ end }}
27
+
</div>
28
+
</div>
29
+
<p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p>
30
+
</fieldset>
31
+
32
+
<div class="space-y-2">
33
+
<button type="submit" class="btn">fork repo</button>
34
+
<div id="repo" class="error"></div>
35
+
</div>
36
+
</form>
37
+
</div>
38
+
{{ end }}
+34
appview/pages/templates/repo/fragments/artifact.html
+34
appview/pages/templates/repo/fragments/artifact.html
···
···
1
+
{{ define "repo/fragments/artifact" }}
2
+
{{ $unique := .Artifact.BlobCid.String }}
3
+
<div id="artifact-{{ $unique }}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
4
+
<div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]">
5
+
{{ i "box" "w-4 h-4" }}
6
+
<a href="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/download/{{ .Artifact.Name | urlquery }}" class="no-underline hover:no-underline">
7
+
{{ .Artifact.Name }}
8
+
</a>
9
+
<span class="text-gray-500 dark:text-gray-400 pl-2 text-sm">{{ byteFmt .Artifact.Size }}</span>
10
+
</div>
11
+
12
+
<div id="right-side" class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm">
13
+
<span title="{{ longTimeFmt .Artifact.CreatedAt }}" class="hidden md:inline">{{ timeFmt .Artifact.CreatedAt }}</span>
14
+
<span title="{{ longTimeFmt .Artifact.CreatedAt }}" class=" md:hidden">{{ shortTimeFmt .Artifact.CreatedAt }}</span>
15
+
16
+
<span class="select-none after:content-['ยท'] hidden md:inline"></span>
17
+
<span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.MimeType }}</span>
18
+
19
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Artifact.Did) }}
20
+
<button
21
+
id="delete-{{ $unique }}"
22
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2"
23
+
title="Delete artifact"
24
+
hx-delete="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/{{ .Artifact.Name | urlquery }}"
25
+
hx-swap="outerHTML"
26
+
hx-target="#artifact-{{ $unique }}"
27
+
hx-disabled-elt="#delete-{{ $unique }}"
28
+
hx-confirm="Are you sure you want to delete the artifact '{{ .Artifact.Name }}'?">
29
+
{{ i "trash-2" "w-4 h-4" }}
30
+
</button>
31
+
{{ end }}
32
+
</div>
33
+
</div>
34
+
{{ end }}
+55
appview/pages/templates/repo/fragments/cloneInstructions.html
+55
appview/pages/templates/repo/fragments/cloneInstructions.html
···
···
1
+
{{ define "repo/fragments/cloneInstructions" }}
2
+
{{ $knot := .RepoInfo.Knot }}
3
+
{{ if eq $knot "knot1.tangled.sh" }}
4
+
{{ $knot = "tangled.sh" }}
5
+
{{ end }}
6
+
<section
7
+
class="mt-4 p-6 rounded drop-shadow-sm bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4"
8
+
>
9
+
<div class="flex flex-col gap-2">
10
+
<strong>push</strong>
11
+
<div class="md:pl-4 overflow-x-auto whitespace-nowrap">
12
+
<code class="dark:text-gray-100"
13
+
>git remote add origin
14
+
git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code
15
+
>
16
+
</div>
17
+
</div>
18
+
19
+
<div class="flex flex-col gap-2">
20
+
<strong>clone</strong>
21
+
<div class="md:pl-4 flex flex-col gap-2">
22
+
<div class="flex items-center gap-3">
23
+
<span
24
+
class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white"
25
+
>HTTP</span
26
+
>
27
+
<div class="overflow-x-auto whitespace-nowrap flex-1">
28
+
<code class="dark:text-gray-100"
29
+
>git clone
30
+
https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code
31
+
>
32
+
</div>
33
+
</div>
34
+
35
+
<div class="flex items-center gap-3">
36
+
<span
37
+
class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white"
38
+
>SSH</span
39
+
>
40
+
<div class="overflow-x-auto whitespace-nowrap flex-1">
41
+
<code class="dark:text-gray-100"
42
+
>git clone
43
+
git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code
44
+
>
45
+
</div>
46
+
</div>
47
+
</div>
48
+
</div>
49
+
50
+
<p class="py-2 text-gray-500 dark:text-gray-400">
51
+
Note that for self-hosted knots, clone URLs may be different based
52
+
on your setup.
53
+
</p>
54
+
</section>
55
+
{{ end }}
+163
appview/pages/templates/repo/fragments/diff.html
+163
appview/pages/templates/repo/fragments/diff.html
···
···
1
+
{{ define "repo/fragments/diff" }}
2
+
{{ $repo := index . 0 }}
3
+
{{ $diff := index . 1 }}
4
+
{{ $commit := $diff.Commit }}
5
+
{{ $stat := $diff.Stat }}
6
+
{{ $fileTree := fileTree $diff.ChangedFiles }}
7
+
{{ $diff := $diff.Diff }}
8
+
9
+
{{ $this := $commit.This }}
10
+
{{ $parent := $commit.Parent }}
11
+
12
+
<section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
13
+
<div class="diff-stat">
14
+
<div class="flex gap-2 items-center">
15
+
<strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
16
+
{{ block "statPill" $stat }} {{ end }}
17
+
</div>
18
+
{{ block "fileTree" $fileTree }} {{ end }}
19
+
</div>
20
+
</section>
21
+
22
+
{{ $last := sub (len $diff) 1 }}
23
+
{{ range $idx, $hunk := $diff }}
24
+
{{ with $hunk }}
25
+
<section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
26
+
<div id="file-{{ .Name.New }}">
27
+
<div id="diff-file">
28
+
<details open>
29
+
<summary class="list-none cursor-pointer sticky top-0">
30
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
31
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
32
+
<div class="flex gap-1 items-center">
33
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
34
+
{{ if .IsNew }}
35
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span>
36
+
{{ else if .IsDelete }}
37
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span>
38
+
{{ else if .IsCopy }}
39
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span>
40
+
{{ else if .IsRename }}
41
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span>
42
+
{{ else }}
43
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span>
44
+
{{ end }}
45
+
46
+
{{ block "statPill" .Stats }} {{ end }}
47
+
</div>
48
+
49
+
<div class="flex gap-2 items-center overflow-x-auto">
50
+
{{ if .IsDelete }}
51
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
52
+
{{ .Name.Old }}
53
+
</a>
54
+
{{ else if (or .IsCopy .IsRename) }}
55
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}>
56
+
{{ .Name.Old }}
57
+
</a>
58
+
{{ i "arrow-right" "w-4 h-4" }}
59
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
60
+
{{ .Name.New }}
61
+
</a>
62
+
{{ else }}
63
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
64
+
{{ .Name.New }}
65
+
</a>
66
+
{{ end }}
67
+
</div>
68
+
</div>
69
+
70
+
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
71
+
<div id="right-side-items" class="p-2 flex items-center">
72
+
<a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
73
+
{{ if gt $idx 0 }}
74
+
{{ $prev := index $diff (sub $idx 1) }}
75
+
<a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
76
+
{{ end }}
77
+
78
+
{{ if lt $idx $last }}
79
+
{{ $next := index $diff (add $idx 1) }}
80
+
<a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
81
+
{{ end }}
82
+
</div>
83
+
84
+
</div>
85
+
</summary>
86
+
87
+
<div class="transition-all duration-700 ease-in-out">
88
+
{{ if .IsDelete }}
89
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
90
+
This file has been deleted.
91
+
</p>
92
+
{{ else if .IsCopy }}
93
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
94
+
This file has been copied.
95
+
</p>
96
+
{{ else if .IsBinary }}
97
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
98
+
This is a binary file and will not be displayed.
99
+
</p>
100
+
{{ else }}
101
+
{{ $name := .Name.New }}
102
+
<pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
103
+
{{- $oldStart := .OldPosition -}}
104
+
{{- $newStart := .NewPosition -}}
105
+
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}}
106
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
107
+
{{- $lineNrSepStyle1 := "" -}}
108
+
{{- $lineNrSepStyle2 := "pr-2" -}}
109
+
{{- range .Lines -}}
110
+
{{- if eq .Op.String "+" -}}
111
+
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center">
112
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
113
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
114
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
115
+
<div class="px-2">{{ .Line }}</div>
116
+
</div>
117
+
{{- $newStart = add64 $newStart 1 -}}
118
+
{{- end -}}
119
+
{{- if eq .Op.String "-" -}}
120
+
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center">
121
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
122
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
123
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
124
+
<div class="px-2">{{ .Line }}</div>
125
+
</div>
126
+
{{- $oldStart = add64 $oldStart 1 -}}
127
+
{{- end -}}
128
+
{{- if eq .Op.String " " -}}
129
+
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center">
130
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
131
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
132
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
133
+
<div class="px-2">{{ .Line }}</div>
134
+
</div>
135
+
{{- $newStart = add64 $newStart 1 -}}
136
+
{{- $oldStart = add64 $oldStart 1 -}}
137
+
{{- end -}}
138
+
{{- end -}}
139
+
{{- end -}}</div></div></pre>
140
+
{{- end -}}
141
+
</div>
142
+
143
+
</details>
144
+
145
+
</div>
146
+
</div>
147
+
</section>
148
+
{{ end }}
149
+
{{ end }}
150
+
{{ end }}
151
+
152
+
{{ define "statPill" }}
153
+
<div class="flex items-center font-mono text-sm">
154
+
{{ if and .Insertions .Deletions }}
155
+
<span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
156
+
<span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
157
+
{{ else if .Insertions }}
158
+
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
159
+
{{ else if .Deletions }}
160
+
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
161
+
{{ end }}
162
+
</div>
163
+
{{ end }}
+11
appview/pages/templates/repo/fragments/editRepoDescription.html
+11
appview/pages/templates/repo/fragments/editRepoDescription.html
···
···
1
+
{{ define "repo/fragments/editRepoDescription" }}
2
+
<form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2">
3
+
<input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}">
4
+
<button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm">
5
+
{{ i "check" "w-3 h-3" }} save
6
+
</button>
7
+
<button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" >
8
+
{{ i "x" "w-3 h-3" }} cancel
9
+
</button>
10
+
</form>
11
+
{{ end }}
+27
appview/pages/templates/repo/fragments/filetree.html
+27
appview/pages/templates/repo/fragments/filetree.html
···
···
1
+
{{ define "fileTree" }}
2
+
{{ if and .Name .IsDirectory }}
3
+
<details open>
4
+
<summary class="cursor-pointer list-none pt-1">
5
+
<span class="tree-directory inline-flex items-center gap-2 ">
6
+
{{ i "folder" "size-4 fill-current" }}
7
+
<span class="filename text-black dark:text-white">{{ .Name }}</span>
8
+
</span>
9
+
</summary>
10
+
<div class="ml-1 pl-4 border-l border-gray-200 dark:border-gray-700">
11
+
{{ range $child := .Children }}
12
+
{{ block "fileTree" $child }} {{ end }}
13
+
{{ end }}
14
+
</div>
15
+
</details>
16
+
{{ else if .Name }}
17
+
<div class="tree-file flex items-center gap-2 pt-1">
18
+
{{ i "file" "size-4" }}
19
+
<a href="#file-{{ .Path }}" class="filename text-black dark:text-white no-underline hover:underline">{{ .Name }}</a>
20
+
</div>
21
+
{{ else }}
22
+
{{ range $child := .Children }}
23
+
{{ block "fileTree" $child }} {{ end }}
24
+
{{ end }}
25
+
{{ end }}
26
+
{{ end }}
27
+
+143
appview/pages/templates/repo/fragments/interdiff.html
+143
appview/pages/templates/repo/fragments/interdiff.html
···
···
1
+
{{ define "repo/fragments/interdiff" }}
2
+
{{ $repo := index . 0 }}
3
+
{{ $x := index . 1 }}
4
+
{{ $fileTree := fileTree $x.AffectedFiles }}
5
+
{{ $diff := $x.Files }}
6
+
7
+
<section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
8
+
<div class="diff-stat">
9
+
<div class="flex gap-2 items-center">
10
+
<strong class="text-sm uppercase dark:text-gray-200">files</strong>
11
+
</div>
12
+
{{ block "fileTree" $fileTree }} {{ end }}
13
+
</div>
14
+
</section>
15
+
16
+
{{ $last := sub (len $diff) 1 }}
17
+
{{ range $idx, $hunk := $diff }}
18
+
{{ with $hunk }}
19
+
<section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
20
+
<div id="file-{{ .Name }}">
21
+
<div id="diff-file">
22
+
<details {{ if not (.Status.IsOnlyInOne) }}open{{end}}>
23
+
<summary class="list-none cursor-pointer sticky top-0">
24
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
25
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
26
+
<div class="flex gap-1 items-center" style="direction: ltr;">
27
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
28
+
{{ if .Status.IsOk }}
29
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span>
30
+
{{ else if .Status.IsUnchanged }}
31
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span>
32
+
{{ else if .Status.IsOnlyInOne }}
33
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span>
34
+
{{ else if .Status.IsOnlyInTwo }}
35
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span>
36
+
{{ else if .Status.IsRebased }}
37
+
<span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span>
38
+
{{ else }}
39
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span>
40
+
{{ end }}
41
+
</div>
42
+
43
+
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
44
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" href="">
45
+
{{ .Name }}
46
+
</a>
47
+
</div>
48
+
</div>
49
+
50
+
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
51
+
<div id="right-side-items" class="p-2 flex items-center">
52
+
<a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
53
+
{{ if gt $idx 0 }}
54
+
{{ $prev := index $diff (sub $idx 1) }}
55
+
<a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
56
+
{{ end }}
57
+
58
+
{{ if lt $idx $last }}
59
+
{{ $next := index $diff (add $idx 1) }}
60
+
<a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
61
+
{{ end }}
62
+
</div>
63
+
64
+
</div>
65
+
</summary>
66
+
67
+
<div class="transition-all duration-700 ease-in-out">
68
+
{{ if .Status.IsUnchanged }}
69
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
70
+
This file has not been changed.
71
+
</p>
72
+
{{ else if .Status.IsRebased }}
73
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
74
+
This patch was likely rebased, as context lines do not match.
75
+
</p>
76
+
{{ else if .Status.IsError }}
77
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
78
+
Failed to calculate interdiff for this file.
79
+
</p>
80
+
{{ else }}
81
+
{{ $name := .Name }}
82
+
<pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
83
+
{{- $oldStart := .OldPosition -}}
84
+
{{- $newStart := .NewPosition -}}
85
+
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}}
86
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
87
+
{{- $lineNrSepStyle1 := "" -}}
88
+
{{- $lineNrSepStyle2 := "pr-2" -}}
89
+
{{- range .Lines -}}
90
+
{{- if eq .Op.String "+" -}}
91
+
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center">
92
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
93
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
94
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
95
+
<div class="px-2">{{ .Line }}</div>
96
+
</div>
97
+
{{- $newStart = add64 $newStart 1 -}}
98
+
{{- end -}}
99
+
{{- if eq .Op.String "-" -}}
100
+
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center">
101
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
102
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
103
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
104
+
<div class="px-2">{{ .Line }}</div>
105
+
</div>
106
+
{{- $oldStart = add64 $oldStart 1 -}}
107
+
{{- end -}}
108
+
{{- if eq .Op.String " " -}}
109
+
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center">
110
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
111
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
112
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
113
+
<div class="px-2">{{ .Line }}</div>
114
+
</div>
115
+
{{- $newStart = add64 $newStart 1 -}}
116
+
{{- $oldStart = add64 $oldStart 1 -}}
117
+
{{- end -}}
118
+
{{- end -}}
119
+
{{- end -}}</div></div></pre>
120
+
{{- end -}}
121
+
</div>
122
+
123
+
</details>
124
+
125
+
</div>
126
+
</div>
127
+
</section>
128
+
{{ end }}
129
+
{{ end }}
130
+
{{ end }}
131
+
132
+
{{ define "statPill" }}
133
+
<div class="flex items-center font-mono text-sm">
134
+
{{ if and .Insertions .Deletions }}
135
+
<span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
136
+
<span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
137
+
{{ else if .Insertions }}
138
+
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
139
+
{{ else if .Deletions }}
140
+
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
141
+
{{ end }}
142
+
</div>
143
+
{{ end }}
+48
appview/pages/templates/repo/fragments/repoActions.html
+48
appview/pages/templates/repo/fragments/repoActions.html
···
···
1
+
{{ define "repo/fragments/repoActions" }}
2
+
<div class="flex items-center gap-2 z-auto">
3
+
<button
4
+
id="starBtn"
5
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
6
+
{{ if .IsStarred }}
7
+
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
8
+
{{ else }}
9
+
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
10
+
{{ end }}
11
+
12
+
hx-trigger="click"
13
+
hx-target="#starBtn"
14
+
hx-swap="outerHTML"
15
+
hx-disabled-elt="#starBtn"
16
+
>
17
+
{{ if .IsStarred }}
18
+
{{ i "star" "w-4 h-4 fill-current" }}
19
+
{{ else }}
20
+
{{ i "star" "w-4 h-4" }}
21
+
{{ end }}
22
+
<span class="text-sm">
23
+
{{ .Stats.StarCount }}
24
+
</span>
25
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
26
+
</button>
27
+
{{ if .DisableFork }}
28
+
<button
29
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
30
+
disabled
31
+
title="Empty repositories cannot be forked"
32
+
>
33
+
{{ i "git-fork" "w-4 h-4" }}
34
+
fork
35
+
</button>
36
+
{{ else }}
37
+
<a
38
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
39
+
hx-boost="true"
40
+
href="/{{ .FullName }}/fork"
41
+
>
42
+
{{ i "git-fork" "w-4 h-4" }}
43
+
fork
44
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
45
+
</a>
46
+
{{ end }}
47
+
</div>
48
+
{{ end }}
+15
appview/pages/templates/repo/fragments/repoDescription.html
+15
appview/pages/templates/repo/fragments/repoDescription.html
···
···
1
+
{{ define "repo/fragments/repoDescription" }}
2
+
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
3
+
{{ if .RepoInfo.Description }}
4
+
{{ .RepoInfo.Description }}
5
+
{{ else }}
6
+
<span class="italic">this repo has no description</span>
7
+
{{ end }}
8
+
9
+
{{ if .RepoInfo.Roles.IsOwner }}
10
+
<button class="flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit">
11
+
{{ i "pencil" "w-3 h-3" }}
12
+
</button>
13
+
{{ end }}
14
+
</span>
15
+
{{ end }}
+298
-196
appview/pages/templates/repo/index.html
+298
-196
appview/pages/templates/repo/index.html
···
1
{{ define "title" }}{{ .RepoInfo.FullName }} at {{ .Ref }}{{ end }}
2
3
-
4
{{ define "extrameta" }}
5
-
<meta name="vcs:clone" content="https://tangled.sh/{{ .RepoInfo.FullName }}"/>
6
-
<meta name="forge:summary" content="https://tangled.sh/{{ .RepoInfo.FullName }}">
7
-
<meta name="forge:dir" content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}">
8
-
<meta name="forge:file" content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}">
9
-
<meta name="forge:line" content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}">
10
-
<meta name="go-import" content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}">
11
{{ end }}
12
13
-
14
{{ define "repoContent" }}
15
<main>
16
-
{{ block "branchSelector" . }} {{ end }}
17
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
18
-
{{ block "fileTree" . }} {{ end }}
19
-
{{ block "commitLog" . }} {{ end }}
20
</div>
21
</main>
22
{{ end }}
23
24
{{ define "branchSelector" }}
25
-
<div class="flex justify-between pb-5">
26
-
<select
27
-
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
28
-
class="p-1 border border-gray-200 bg-white"
29
-
>
30
-
<optgroup label="branches" class="bold text-sm">
31
-
{{ range .Branches }}
32
-
<option
33
-
value="{{ .Reference.Name }}"
34
-
class="py-1"
35
-
{{ if eq .Reference.Name $.Ref }}
36
-
selected
37
-
{{ end }}
38
-
>
39
-
{{ .Reference.Name }}
40
-
</option>
41
-
{{ end }}
42
-
</optgroup>
43
-
<optgroup label="tags" class="bold text-sm">
44
-
{{ range .Tags }}
45
-
<option
46
-
value="{{ .Reference.Name }}"
47
-
class="py-1"
48
-
{{ if eq .Reference.Name $.Ref }}
49
-
selected
50
-
{{ end }}
51
-
>
52
-
{{ .Reference.Name }}
53
-
</option>
54
-
{{ else }}
55
-
<option class="py-1" disabled>no tags found</option>
56
-
{{ end }}
57
-
</optgroup>
58
-
</select>
59
-
<a
60
-
href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}"
61
-
class="ml-2 no-underline flex items-center gap-2 text-sm uppercase font-bold"
62
-
>
63
-
{{ i "logs" "w-4 h-4" }}
64
-
{{ .TotalCommits }}
65
-
{{ if eq .TotalCommits 1 }}commit{{ else }}commits{{ end }}
66
-
</a>
67
-
</div>
68
{{ end }}
69
70
{{ define "fileTree" }}
71
-
<div id="file-tree" class="col-span-1 pr-2 md:border-r md:border-gray-200">
72
-
{{ $containerstyle := "py-1" }}
73
-
{{ $linkstyle := "no-underline hover:underline" }}
74
75
-
{{ range .Files }}
76
-
{{ if not .IsFile }}
77
-
<div class="{{ $containerstyle }}">
78
-
<div class="flex justify-between items-center">
79
-
<a
80
-
href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}"
81
-
class="{{ $linkstyle }}"
82
-
>
83
-
<div class="flex items-center gap-2">
84
-
{{ i "folder" "w-3 h-3 fill-current" }}
85
-
{{ .Name }}
86
-
</div>
87
-
</a>
88
89
-
<time class="text-xs text-gray-500"
90
-
>{{ timeFmt .LastCommit.When }}</time
91
-
>
92
</div>
93
-
</div>
94
{{ end }}
95
-
{{ end }}
96
97
-
{{ range .Files }}
98
-
{{ if .IsFile }}
99
-
<div class="{{ $containerstyle }}">
100
-
<div class="flex justify-between items-center">
101
-
<a
102
-
href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}"
103
-
class="{{ $linkstyle }}"
104
-
>
105
-
<div class="flex items-center gap-2">
106
-
{{ i "file" "w-3 h-3" }}{{ .Name }}
107
-
</div>
108
-
</a>
109
110
-
<time class="text-xs text-gray-500"
111
-
>{{ timeFmt .LastCommit.When }}</time
112
-
>
113
</div>
114
-
</div>
115
{{ end }}
116
-
{{ end }}
117
-
</div>
118
{{ end }}
119
120
121
{{ define "commitLog" }}
122
-
<div id="commit-log" class="hidden md:block md:col-span-1">
123
-
{{ range .Commits }}
124
-
<div class="relative px-2 pb-8">
125
-
<div id="commit-message">
126
-
{{ $messageParts := splitN .Message "\n\n" 2 }}
127
-
<div class="text-base cursor-pointer">
128
-
<div>
129
-
<div>
130
-
<a
131
-
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
132
-
class="inline no-underline hover:underline"
133
-
>{{ index $messageParts 0 }}</a
134
-
>
135
-
{{ if gt (len $messageParts) 1 }}
136
-
137
-
<button
138
-
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded"
139
-
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"
140
-
>
141
-
{{ i "ellipsis" "w-3 h-3" }}
142
-
</button>
143
-
{{ end }}
144
-
</div>
145
-
{{ if gt (len $messageParts) 1 }}
146
-
<p
147
-
class="hidden mt-1 text-sm cursor-text pb-2"
148
-
>
149
-
{{ nl2br (unwrapText (index $messageParts 1)) }}
150
-
</p>
151
-
{{ end }}
152
-
</div>
153
-
</div>
154
-
</div>
155
156
-
<div class="text-xs text-gray-500">
157
-
<span class="font-mono">
158
-
<a
159
-
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
160
-
class="text-gray-500 no-underline hover:underline"
161
-
>{{ slice .Hash.String 0 8 }}</a
162
-
>
163
-
</span>
164
-
<span
165
-
class="mx-2 before:content-['ยท'] before:select-none"
166
-
></span>
167
-
<span>
168
-
{{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }}
169
-
<a
170
-
href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ .Author.Email }}{{ end }}"
171
-
class="text-gray-500 no-underline hover:underline"
172
-
>{{ if $didOrHandle }}{{ $didOrHandle }}{{ else }}{{ .Author.Name }}{{ end }}</a
173
-
>
174
-
</span>
175
-
<div
176
-
class="inline-block px-1 select-none after:content-['ยท']"
177
-
></div>
178
-
<span>{{ timeFmt .Author.When }}</span>
179
-
{{ $tagsForCommit := index $.TagMap .Hash.String }}
180
-
{{ if gt (len $tagsForCommit) 0 }}
181
-
<div
182
-
class="inline-block px-1 select-none after:content-['ยท']"
183
-
></div>
184
-
{{ end }}
185
-
{{ range $tagsForCommit }}
186
-
<span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
187
-
{{ . }}
188
-
</span>
189
-
{{ end }}
190
-
</div>
191
</div>
192
{{ end }}
193
</div>
194
{{ end }}
195
196
197
{{ define "repoAfter" }}
198
{{- if .HTMLReadme }}
199
-
<section class="mt-4 p-6 rounded bg-white w-full mx-auto overflow-auto {{ if not .Raw }} prose {{ end }}">
200
-
<article class="{{ if .Raw }}whitespace-pre{{end}}">
201
{{ if .Raw }}
202
-
<pre>{{ .HTMLReadme }}</pre>
203
{{ else }}
204
{{ .HTMLReadme }}
205
{{ end }}
···
207
</section>
208
{{- end -}}
209
210
-
211
-
<section class="mt-4 p-6 rounded bg-white w-full mx-auto overflow-auto flex flex-col gap-4">
212
-
<div class="flex flex-col gap-2">
213
-
<strong>push</strong>
214
-
<div class="md:pl-4 overflow-x-auto whitespace-nowrap">
215
-
<code>git remote add origin git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
216
-
</div>
217
-
</div>
218
-
219
-
<div class="flex flex-col gap-2">
220
-
<strong>clone</strong>
221
-
<div class="md:pl-4 flex flex-col gap-2">
222
-
223
-
<div class="flex items-center gap-3">
224
-
<span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">HTTP</span>
225
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
226
-
<code>git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
227
-
</div>
228
-
</div>
229
-
230
-
<div class="flex items-center gap-3">
231
-
<span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">SSH</span>
232
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
233
-
<code>git clone git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
234
-
</div>
235
-
</div>
236
-
</div>
237
-
</div>
238
-
239
-
240
-
<p class="py-2 text-gray-500">Note that for self-hosted knots, clone URLs may be different based on your setup.</p>
241
-
</section>
242
{{ end }}
···
1
{{ define "title" }}{{ .RepoInfo.FullName }} at {{ .Ref }}{{ end }}
2
3
{{ define "extrameta" }}
4
+
<meta
5
+
name="vcs:clone"
6
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
7
+
/>
8
+
<meta
9
+
name="forge:summary"
10
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
11
+
/>
12
+
<meta
13
+
name="forge:dir"
14
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}"
15
+
/>
16
+
<meta
17
+
name="forge:file"
18
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}"
19
+
/>
20
+
<meta
21
+
name="forge:line"
22
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}"
23
+
/>
24
+
<meta
25
+
name="go-import"
26
+
content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}"
27
+
/>
28
{{ end }}
29
30
{{ define "repoContent" }}
31
<main>
32
+
<div class="flex items-center justify-between pb-5">
33
+
{{ block "branchSelector" . }}{{ end }}
34
+
<div class="flex md:hidden items-center gap-4">
35
+
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1">
36
+
{{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }}
37
+
</a>
38
+
<a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1">
39
+
{{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }}
40
+
</a>
41
+
<a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1">
42
+
{{ i "tags" "w-4" "h-4" }} {{ len .Tags }}
43
+
</a>
44
+
</div>
45
+
</div>
46
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
47
+
{{ block "fileTree" . }}{{ end }}
48
+
{{ block "rightInfo" . }}{{ end }}
49
</div>
50
</main>
51
{{ end }}
52
53
{{ define "branchSelector" }}
54
+
<select
55
+
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
56
+
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
57
+
>
58
+
<optgroup label="branches ({{len .Branches}})" class="bold text-sm">
59
+
{{ range .Branches }}
60
+
<option
61
+
value="{{ .Reference.Name }}"
62
+
class="py-1"
63
+
{{ if eq .Reference.Name $.Ref }}
64
+
selected
65
+
{{ end }}
66
+
>
67
+
{{ .Reference.Name }}
68
+
</option>
69
+
{{ end }}
70
+
</optgroup>
71
+
<optgroup label="tags ({{len .Tags}})" class="bold text-sm">
72
+
{{ range .Tags }}
73
+
<option
74
+
value="{{ .Reference.Name }}"
75
+
class="py-1"
76
+
{{ if eq .Reference.Name $.Ref }}
77
+
selected
78
+
{{ end }}
79
+
>
80
+
{{ .Reference.Name }}
81
+
</option>
82
+
{{ else }}
83
+
<option class="py-1" disabled>no tags found</option>
84
+
{{ end }}
85
+
</optgroup>
86
+
</select>
87
{{ end }}
88
89
{{ define "fileTree" }}
90
+
<div
91
+
id="file-tree"
92
+
class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700"
93
+
>
94
+
{{ $containerstyle := "py-1" }}
95
+
{{ $linkstyle := "no-underline hover:underline dark:text-white" }}
96
97
+
{{ range .Files }}
98
+
{{ if not .IsFile }}
99
+
<div class="{{ $containerstyle }}">
100
+
<div class="flex justify-between items-center">
101
+
<a
102
+
href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}"
103
+
class="{{ $linkstyle }}"
104
+
>
105
+
<div class="flex items-center gap-2">
106
+
{{ i "folder" "size-4 fill-current" }}
107
+
{{ .Name }}
108
+
</div>
109
+
</a>
110
111
+
<time class="text-xs text-gray-500 dark:text-gray-400"
112
+
>{{ timeFmt .LastCommit.When }}</time
113
+
>
114
+
</div>
115
</div>
116
+
{{ end }}
117
{{ end }}
118
119
+
{{ range .Files }}
120
+
{{ if .IsFile }}
121
+
<div class="{{ $containerstyle }}">
122
+
<div class="flex justify-between items-center">
123
+
<a
124
+
href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}"
125
+
class="{{ $linkstyle }}"
126
+
>
127
+
<div class="flex items-center gap-2">
128
+
{{ i "file" "size-4" }}{{ .Name }}
129
+
</div>
130
+
</a>
131
132
+
<time class="text-xs text-gray-500 dark:text-gray-400"
133
+
>{{ timeFmt .LastCommit.When }}</time
134
+
>
135
+
</div>
136
</div>
137
+
{{ end }}
138
{{ end }}
139
+
</div>
140
{{ end }}
141
142
+
{{ define "rightInfo" }}
143
+
<div id="right-info" class="hidden md:block col-span-1">
144
+
{{ block "commitLog" . }} {{ end }}
145
+
{{ block "branchList" . }} {{ end }}
146
+
{{ block "tagList" . }} {{ end }}
147
+
</div>
148
+
{{ end }}
149
150
{{ define "commitLog" }}
151
+
<div id="commit-log" class="md:col-span-1 px-2 pb-4">
152
+
<div class="flex justify-between items-center">
153
+
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
154
+
<div class="flex gap-2 items-center font-bold">
155
+
{{ i "logs" "w-4 h-4" }} commits
156
+
</div>
157
+
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
158
+
view {{ .TotalCommits }} commits {{ i "chevron-right" "w-4 h-4" }}
159
+
</span>
160
+
</a>
161
+
</div>
162
+
<div class="flex flex-col gap-6">
163
+
{{ range .CommitsTrunc }}
164
+
<div>
165
+
<div id="commit-message">
166
+
{{ $messageParts := splitN .Message "\n\n" 2 }}
167
+
<div class="text-base cursor-pointer">
168
+
<div>
169
+
<div>
170
+
<a
171
+
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
172
+
class="inline no-underline hover:underline dark:text-white"
173
+
>{{ index $messageParts 0 }}</a
174
+
>
175
+
{{ if gt (len $messageParts) 1 }}
176
177
+
<button
178
+
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
179
+
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"
180
+
>
181
+
{{ i "ellipsis" "w-3 h-3" }}
182
+
</button>
183
+
{{ end }}
184
+
</div>
185
+
{{ if gt (len $messageParts) 1 }}
186
+
<p
187
+
class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300"
188
+
>
189
+
{{ nl2br (index $messageParts 1) }}
190
+
</p>
191
+
{{ end }}
192
</div>
193
+
</div>
194
+
</div>
195
+
196
+
<div class="text-xs text-gray-500 dark:text-gray-400">
197
+
<span class="font-mono">
198
+
<a
199
+
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
200
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
201
+
>{{ slice .Hash.String 0 8 }}</a
202
+
></span
203
+
>
204
+
<span
205
+
class="mx-2 before:content-['ยท'] before:select-none"
206
+
></span>
207
+
<span>
208
+
{{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }}
209
+
<a
210
+
href="{{ if $didOrHandle }}
211
+
/{{ $didOrHandle }}
212
+
{{ else }}
213
+
mailto:{{ .Author.Email }}
214
+
{{ end }}"
215
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
216
+
>{{ if $didOrHandle }}
217
+
{{ $didOrHandle }}
218
+
{{ else }}
219
+
{{ .Author.Name }}
220
+
{{ end }}</a
221
+
>
222
+
</span>
223
+
<div
224
+
class="inline-block px-1 select-none after:content-['ยท']"
225
+
></div>
226
+
<span>{{ timeFmt .Author.When }}</span>
227
+
{{ $tagsForCommit := index $.TagMap .Hash.String }}
228
+
{{ if gt (len $tagsForCommit) 0 }}
229
+
<div
230
+
class="inline-block px-1 select-none after:content-['ยท']"
231
+
></div>
232
+
{{ end }}
233
+
{{ range $tagsForCommit }}
234
+
<span
235
+
class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"
236
+
>
237
+
{{ . }}
238
+
</span>
239
+
{{ end }}
240
+
</div>
241
+
</div>
242
{{ end }}
243
+
</div>
244
</div>
245
{{ end }}
246
247
+
{{ define "branchList" }}
248
+
{{ if gt (len .BranchesTrunc) 0 }}
249
+
<div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
250
+
<a href="/{{ .RepoInfo.FullName }}/branches" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
251
+
<div class="flex gap-2 items-center font-bold">
252
+
{{ i "git-branch" "w-4 h-4" }} branches
253
+
</div>
254
+
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
255
+
view {{ len .Branches }} branches {{ i "chevron-right" "w-4 h-4" }}
256
+
</span>
257
+
</a>
258
+
<div class="flex flex-col gap-1">
259
+
{{ range .BranchesTrunc }}
260
+
<div class="text-base flex items-center gap-2">
261
+
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}"
262
+
class="inline no-underline hover:underline dark:text-white">
263
+
{{ .Reference.Name }}
264
+
</a>
265
+
{{ if .Commit }}
266
+
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
267
+
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Commit.Author.When }}</time>
268
+
{{ end }}
269
+
{{ if .IsDefault }}
270
+
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
271
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono">default</span>
272
+
{{ end }}
273
+
</div>
274
+
{{ end }}
275
+
</div>
276
+
</div>
277
+
{{ end }}
278
+
{{ end }}
279
+
280
+
{{ define "tagList" }}
281
+
{{ if gt (len .TagsTrunc) 0 }}
282
+
<div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
283
+
<div class="flex justify-between items-center">
284
+
<a href="/{{ .RepoInfo.FullName }}/tags" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
285
+
<div class="flex gap-2 items-center font-bold">
286
+
{{ i "tags" "w-4 h-4" }} tags
287
+
</div>
288
+
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
289
+
view {{ len .Tags }} tags {{ i "chevron-right" "w-4 h-4" }}
290
+
</span>
291
+
</a>
292
+
</div>
293
+
<div class="flex flex-col gap-1">
294
+
{{ range $idx, $tag := .TagsTrunc }}
295
+
{{ with $tag }}
296
+
<div>
297
+
<div class="text-base flex items-center gap-2">
298
+
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}"
299
+
class="inline no-underline hover:underline dark:text-white">
300
+
{{ .Reference.Name }}
301
+
</a>
302
+
</div>
303
+
<div>
304
+
{{ with .Tag }}
305
+
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Tagger.When }}</time>
306
+
{{ end }}
307
+
{{ if eq $idx 0 }}
308
+
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
309
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono">latest</span>
310
+
{{ end }}
311
+
</div>
312
+
</div>
313
+
{{ end }}
314
+
{{ end }}
315
+
</div>
316
+
</div>
317
+
{{ end }}
318
+
{{ end }}
319
320
{{ define "repoAfter" }}
321
{{- if .HTMLReadme }}
322
+
<section
323
+
class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }}
324
+
prose dark:prose-invert dark:[&_pre]:bg-gray-900
325
+
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
326
+
dark:[&_pre]:border dark:[&_pre]:border-gray-700
327
+
{{ end }}"
328
+
>
329
+
<article class="{{ if .Raw }}whitespace-pre{{ end }}">
330
{{ if .Raw }}
331
+
<pre
332
+
class="dark:bg-gray-900 dark:text-gray-200 dark:border dark:border-gray-700 dark:p-4 dark:rounded"
333
+
>
334
+
{{ .HTMLReadme }}</pre
335
+
>
336
{{ else }}
337
{{ .HTMLReadme }}
338
{{ end }}
···
340
</section>
341
{{- end -}}
342
343
+
{{ template "repo/fragments/cloneInstructions" . }}
344
{{ end }}
+52
appview/pages/templates/repo/issues/fragments/editIssueComment.html
+52
appview/pages/templates/repo/issues/fragments/editIssueComment.html
···
···
1
+
{{ define "repo/issues/fragments/editIssueComment" }}
2
+
{{ with .Comment }}
3
+
<div id="comment-container-{{.CommentId}}">
4
+
<div class="flex items-center gap-2 mb-2 text-gray-500 text-sm">
5
+
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
6
+
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
7
+
8
+
<!-- show user "hats" -->
9
+
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
10
+
{{ if $isIssueAuthor }}
11
+
<span class="before:content-['ยท']"></span>
12
+
<span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
13
+
author
14
+
</span>
15
+
{{ end }}
16
+
17
+
<span class="before:content-['ยท']"></span>
18
+
<a
19
+
href="#{{ .CommentId }}"
20
+
class="text-gray-500 hover:text-gray-500 hover:underline no-underline"
21
+
id="{{ .CommentId }}">
22
+
{{ .Created | timeFmt }}
23
+
</a>
24
+
25
+
<button
26
+
class="btn px-2 py-1 flex items-center gap-2 text-sm"
27
+
hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
28
+
hx-include="#edit-textarea-{{ .CommentId }}"
29
+
hx-target="#comment-container-{{ .CommentId }}"
30
+
hx-swap="outerHTML">
31
+
{{ i "check" "w-4 h-4" }}
32
+
</button>
33
+
<button
34
+
class="btn px-2 py-1 flex items-center gap-2 text-sm"
35
+
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
36
+
hx-target="#comment-container-{{ .CommentId }}"
37
+
hx-swap="outerHTML">
38
+
{{ i "x" "w-4 h-4" }}
39
+
</button>
40
+
<span id="comment-{{.CommentId}}-status"></span>
41
+
</div>
42
+
43
+
<div>
44
+
<textarea
45
+
id="edit-textarea-{{ .CommentId }}"
46
+
name="body"
47
+
class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea>
48
+
</div>
49
+
</div>
50
+
{{ end }}
51
+
{{ end }}
52
+
+59
appview/pages/templates/repo/issues/fragments/issueComment.html
+59
appview/pages/templates/repo/issues/fragments/issueComment.html
···
···
1
+
{{ define "repo/issues/fragments/issueComment" }}
2
+
{{ with .Comment }}
3
+
<div id="comment-container-{{.CommentId}}">
4
+
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm">
5
+
{{ $owner := index $.DidHandleMap .OwnerDid }}
6
+
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
7
+
8
+
<span class="before:content-['ยท']"></span>
9
+
<a
10
+
href="#{{ .CommentId }}"
11
+
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
12
+
id="{{ .CommentId }}">
13
+
{{ if .Deleted }}
14
+
deleted {{ .Deleted | timeFmt }}
15
+
{{ else if .Edited }}
16
+
edited {{ .Edited | timeFmt }}
17
+
{{ else }}
18
+
{{ .Created | timeFmt }}
19
+
{{ end }}
20
+
</a>
21
+
22
+
<!-- show user "hats" -->
23
+
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
24
+
{{ if $isIssueAuthor }}
25
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
26
+
author
27
+
</span>
28
+
{{ end }}
29
+
30
+
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
31
+
{{ if and $isCommentOwner (not .Deleted) }}
32
+
<button
33
+
class="btn px-2 py-1 text-sm"
34
+
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
35
+
hx-swap="outerHTML"
36
+
hx-target="#comment-container-{{.CommentId}}"
37
+
>
38
+
{{ i "pencil" "w-4 h-4" }}
39
+
</button>
40
+
<button
41
+
class="btn px-2 py-1 text-sm text-red-500"
42
+
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
43
+
hx-confirm="Are you sure you want to delete your comment?"
44
+
hx-swap="outerHTML"
45
+
hx-target="#comment-container-{{.CommentId}}"
46
+
>
47
+
{{ i "trash-2" "w-4 h-4" }}
48
+
</button>
49
+
{{ end }}
50
+
51
+
</div>
52
+
{{ if not .Deleted }}
53
+
<div class="prose dark:prose-invert">
54
+
{{ .Body | markdown }}
55
+
</div>
56
+
{{ end }}
57
+
</div>
58
+
{{ end }}
59
+
{{ end }}
+123
-72
appview/pages/templates/repo/issues/issue.html
+123
-72
appview/pages/templates/repo/issues/issue.html
···
1
-
{{ define "title" }}{{ .Issue.Title }} · issue #{{ .Issue.IssueId }} ·{{ .RepoInfo.FullName }}{{ end }}
2
3
{{ define "repoContent" }}
4
<header class="pb-4">
5
<h1 class="text-2xl">
6
{{ .Issue.Title }}
7
-
<span class="text-gray-500">#{{ .Issue.IssueId }}</span>
8
</h1>
9
</header>
10
11
-
{{ $bgColor := "bg-gray-800" }}
12
{{ $icon := "ban" }}
13
{{ if eq .State "open" }}
14
-
{{ $bgColor = "bg-green-600" }}
15
{{ $icon = "circle-dot" }}
16
{{ end }}
17
18
<section class="mt-2">
19
<div class="inline-flex items-center gap-2">
20
<div id="state"
21
-
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }} text-sm">
22
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
23
<span class="text-white">{{ .State }}</span>
24
</div>
25
-
<span class="text-gray-500 text-sm">
26
opened by
27
{{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
28
<a href="/{{ $owner }}" class="no-underline hover:underline"
29
>{{ $owner }}</a
30
>
31
<span class="px-1 select-none before:content-['\00B7']"></span>
32
-
<time>{{ .Issue.Created | timeFmt }}</time>
33
</span>
34
</div>
35
36
{{ if .Issue.Body }}
37
-
<article id="body" class="mt-4 prose">
38
{{ .Issue.Body | markdown }}
39
</article>
40
{{ end }}
···
42
{{ end }}
43
44
{{ define "repoAfter" }}
45
-
{{ if gt (len .Comments) 0 }}
46
-
<section id="comments" class="mt-8 space-y-4 relative">
47
{{ range $index, $comment := .Comments }}
48
<div
49
id="comment-{{ .CommentId }}"
50
-
class="rounded bg-white px-6 py-4 relative"
51
-
>
52
-
{{ if eq $index 0 }}
53
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300" ></div>
54
-
{{ else }}
55
-
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300" ></div>
56
{{ end }}
57
-
<div class="flex items-center gap-2 mb-2 text-gray-500">
58
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
59
-
<span class="text-sm">
60
-
<a
61
-
href="/{{ $owner }}"
62
-
class="no-underline hover:underline"
63
-
>{{ $owner }}</a
64
-
>
65
-
</span>
66
-
67
-
<span class="before:content-['ยท']"></span>
68
-
<a
69
-
href="#{{ .CommentId }}"
70
-
class="text-gray-500 text-sm hover:text-gray-500 hover:underline no-underline"
71
-
id="{{ .CommentId }}"
72
-
>
73
-
{{ .Created | timeFmt }}
74
-
</a>
75
-
</div>
76
-
<div class="prose">
77
-
{{ .Body | markdown }}
78
-
</div>
79
</div>
80
{{ end }}
81
</section>
82
-
{{ end }}
83
84
{{ block "newComment" . }} {{ end }}
85
86
-
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }}
87
-
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
88
-
{{ if or $isIssueAuthor $isRepoCollaborator }}
89
-
{{ $action := "close" }}
90
-
{{ $icon := "circle-x" }}
91
-
{{ $hoverColor := "red" }}
92
-
{{ if eq .State "closed" }}
93
-
{{ $action = "reopen" }}
94
-
{{ $icon = "circle-dot" }}
95
-
{{ $hoverColor = "green" }}
96
-
{{ end }}
97
-
<form
98
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/{{ $action }}"
99
-
hx-swap="none"
100
-
class="mt-8"
101
-
>
102
-
<button type="submit" class="btn hover:bg-{{ $hoverColor }}-300">
103
-
{{ i $icon "w-4 h-4 mr-2" }}
104
-
<span class="text-black">{{ $action }}</span>
105
-
</button>
106
-
<div id="issue-action" class="error"></div>
107
-
</form>
108
-
{{ end }}
109
{{ end }}
110
111
{{ define "newComment" }}
112
{{ if .LoggedInUser }}
113
-
<div class="bg-white rounded drop-shadow-sm py-4 px-6 relative w-full flex flex-col gap-2 mt-8">
114
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300" ></div>
115
-
<div class="text-sm text-gray-500">
116
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
117
</div>
118
-
<form hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment">
119
<textarea
120
name="body"
121
-
class="w-full p-2 rounded border border-gray-200"
122
-
placeholder="Add to the discussion..."
123
></textarea>
124
-
<button type="submit" class="btn mt-2">comment</button>
125
<div id="issue-comment"></div>
126
-
</form>
127
</div>
128
{{ else }}
129
-
<div class="bg-white rounded drop-shadow-sm px-6 py-4 mt-8">
130
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300" ></div>
131
<a href="/login" class="underline">login</a> to join the discussion
132
</div>
133
{{ end }}
···
1
+
{{ define "title" }}{{ .Issue.Title }} · issue #{{ .Issue.IssueId }} · {{ .RepoInfo.FullName }}{{ end }}
2
3
{{ define "repoContent" }}
4
<header class="pb-4">
5
<h1 class="text-2xl">
6
{{ .Issue.Title }}
7
+
<span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span>
8
</h1>
9
</header>
10
11
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
12
{{ $icon := "ban" }}
13
{{ if eq .State "open" }}
14
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
15
{{ $icon = "circle-dot" }}
16
{{ end }}
17
18
<section class="mt-2">
19
<div class="inline-flex items-center gap-2">
20
<div id="state"
21
+
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}">
22
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
23
<span class="text-white">{{ .State }}</span>
24
</div>
25
+
<span class="text-gray-500 dark:text-gray-400 text-sm">
26
opened by
27
{{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
28
<a href="/{{ $owner }}" class="no-underline hover:underline"
29
>{{ $owner }}</a
30
>
31
<span class="px-1 select-none before:content-['\00B7']"></span>
32
+
<time title="{{ .Issue.Created | longTimeFmt }}">
33
+
{{ .Issue.Created | timeFmt }}
34
+
</time>
35
</span>
36
</div>
37
38
{{ if .Issue.Body }}
39
+
<article id="body" class="mt-8 prose dark:prose-invert">
40
{{ .Issue.Body | markdown }}
41
</article>
42
{{ end }}
···
44
{{ end }}
45
46
{{ define "repoAfter" }}
47
+
<section id="comments" class="my-2 mt-2 space-y-2 relative">
48
{{ range $index, $comment := .Comments }}
49
<div
50
id="comment-{{ .CommentId }}"
51
+
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
52
+
{{ if gt $index 0 }}
53
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
54
{{ end }}
55
+
{{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}}
56
</div>
57
{{ end }}
58
</section>
59
60
{{ block "newComment" . }} {{ end }}
61
62
{{ end }}
63
64
{{ define "newComment" }}
65
{{ if .LoggedInUser }}
66
+
<form
67
+
id="comment-form"
68
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
69
+
hx-on::after-request="if(event.detail.successful) this.reset()"
70
+
>
71
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5">
72
+
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
73
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
74
</div>
75
<textarea
76
+
id="comment-textarea"
77
name="body"
78
+
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
79
+
placeholder="Add to the discussion. Markdown is supported."
80
+
onkeyup="updateCommentForm()"
81
></textarea>
82
<div id="issue-comment"></div>
83
+
<div id="issue-action" class="error"></div>
84
+
</div>
85
+
86
+
<div class="flex gap-2 mt-2">
87
+
<button
88
+
id="comment-button"
89
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
90
+
type="submit"
91
+
hx-disabled-elt="#comment-button"
92
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline"
93
+
disabled
94
+
>
95
+
{{ i "message-square-plus" "w-4 h-4" }}
96
+
comment
97
+
</button>
98
+
99
+
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }}
100
+
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
101
+
{{ if and (or $isIssueAuthor $isRepoCollaborator) (eq .State "open") }}
102
+
<button
103
+
id="close-button"
104
+
type="button"
105
+
class="btn flex items-center gap-2"
106
+
hx-trigger="click"
107
+
>
108
+
{{ i "ban" "w-4 h-4" }}
109
+
close
110
+
</button>
111
+
<div
112
+
id="close-with-comment"
113
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
114
+
hx-trigger="click from:#close-button"
115
+
hx-disabled-elt="#close-with-comment"
116
+
hx-target="#issue-comment"
117
+
hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}"
118
+
hx-swap="none"
119
+
>
120
+
</div>
121
+
<div
122
+
id="close-issue"
123
+
hx-disabled-elt="#close-issue"
124
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close"
125
+
hx-trigger="click from:#close-button"
126
+
hx-target="#issue-action"
127
+
hx-swap="none"
128
+
>
129
+
</div>
130
+
<script>
131
+
document.addEventListener('htmx:configRequest', function(evt) {
132
+
if (evt.target.id === 'close-with-comment') {
133
+
const commentText = document.getElementById('comment-textarea').value.trim();
134
+
if (commentText === '') {
135
+
evt.detail.parameters = {};
136
+
evt.preventDefault();
137
+
}
138
+
}
139
+
});
140
+
</script>
141
+
{{ else if and (or $isIssueAuthor $isRepoCollaborator) (eq .State "closed") }}
142
+
<button
143
+
type="button"
144
+
class="btn flex items-center gap-2"
145
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen"
146
+
hx-swap="none"
147
+
>
148
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
149
+
reopen
150
+
</button>
151
+
{{ end }}
152
+
153
+
<script>
154
+
function updateCommentForm() {
155
+
const textarea = document.getElementById('comment-textarea');
156
+
const commentButton = document.getElementById('comment-button');
157
+
const closeButton = document.getElementById('close-button');
158
+
159
+
if (textarea.value.trim() !== '') {
160
+
commentButton.removeAttribute('disabled');
161
+
} else {
162
+
commentButton.setAttribute('disabled', '');
163
+
}
164
+
165
+
if (closeButton) {
166
+
if (textarea.value.trim() !== '') {
167
+
closeButton.innerHTML = '{{ i "ban" "w-4 h-4" }} close with comment';
168
+
} else {
169
+
closeButton.innerHTML = '{{ i "ban" "w-4 h-4" }} close';
170
+
}
171
+
}
172
+
}
173
+
174
+
document.addEventListener('DOMContentLoaded', function() {
175
+
updateCommentForm();
176
+
});
177
+
</script>
178
</div>
179
+
</form>
180
{{ else }}
181
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
182
<a href="/login" class="underline">login</a> to join the discussion
183
</div>
184
{{ end }}
+71
-24
appview/pages/templates/repo/issues/issues.html
+71
-24
appview/pages/templates/repo/issues/issues.html
···
1
{{ define "title" }}issues · {{ .RepoInfo.FullName }}{{ end }}
2
3
{{ define "repoContent" }}
4
-
<div class="flex justify-between items-center">
5
-
<p>
6
-
filtering
7
-
<select class="border px-1 bg-white border-gray-200" onchange="window.location.href = '/{{ .RepoInfo.FullName }}/issues?state=' + this.value">
8
-
<option value="open" {{ if .FilteringByOpen }}selected{{ end }}>open ({{ .RepoInfo.Stats.IssueCount.Open }})</option>
9
-
<option value="closed" {{ if not .FilteringByOpen }}selected{{ end }}>closed ({{ .RepoInfo.Stats.IssueCount.Closed }})</option>
10
-
</select>
11
-
issues
12
-
</p>
13
-
<a
14
-
href="/{{ .RepoInfo.FullName }}/issues/new"
15
-
class="btn text-sm flex items-center gap-2 no-underline hover:no-underline">
16
-
{{ i "plus" "w-4 h-4" }}
17
-
<span>new issue</span>
18
-
</a>
19
-
</div>
20
-
<div class="error" id="issues"></div>
21
{{ end }}
22
23
{{ define "repoAfter" }}
24
<div class="flex flex-col gap-2 mt-2">
25
{{ range .Issues }}
26
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4">
27
<div class="pb-2">
28
<a
29
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
···
33
<span class="text-gray-500">#{{ .IssueId }}</span>
34
</a>
35
</div>
36
-
<p class="text-sm text-gray-500">
37
-
{{ $bgColor := "bg-gray-800" }}
38
{{ $icon := "ban" }}
39
{{ $state := "closed" }}
40
{{ if .Open }}
41
-
{{ $bgColor = "bg-green-600" }}
42
{{ $icon = "circle-dot" }}
43
{{ $state = "open" }}
44
{{ end }}
45
46
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
47
-
{{ i $icon "w-3 h-3 mr-1.5 text-white" }}
48
-
<span class="text-white">{{ $state }}</span>
49
</span>
50
51
<span>
···
64
{{ if eq .Metadata.CommentCount 1 }}
65
{{ $s = "" }}
66
{{ end }}
67
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500">{{ .Metadata.CommentCount }} comment{{$s}}</a>
68
</span>
69
</p>
70
</div>
71
{{ end }}
72
</div>
73
{{ end }}
···
1
{{ define "title" }}issues · {{ .RepoInfo.FullName }}{{ end }}
2
3
{{ define "repoContent" }}
4
+
<div class="flex justify-between items-center gap-4">
5
+
<div class="flex gap-4">
6
+
<a
7
+
href="?state=open"
8
+
class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
9
+
>
10
+
{{ i "circle-dot" "w-4 h-4" }}
11
+
<span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span>
12
+
</a>
13
+
<a
14
+
href="?state=closed"
15
+
class="flex items-center gap-2 {{ if not .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
16
+
>
17
+
{{ i "ban" "w-4 h-4" }}
18
+
<span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span>
19
+
</a>
20
+
</div>
21
+
<a
22
+
href="/{{ .RepoInfo.FullName }}/issues/new"
23
+
class="btn text-sm flex items-center justify-center gap-2 no-underline hover:no-underline"
24
+
>
25
+
{{ i "circle-plus" "w-4 h-4" }}
26
+
<span>new</span>
27
+
</a>
28
+
</div>
29
+
<div class="error" id="issues"></div>
30
{{ end }}
31
32
{{ define "repoAfter" }}
33
<div class="flex flex-col gap-2 mt-2">
34
{{ range .Issues }}
35
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
36
<div class="pb-2">
37
<a
38
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
···
42
<span class="text-gray-500">#{{ .IssueId }}</span>
43
</a>
44
</div>
45
+
<p class="text-sm text-gray-500 dark:text-gray-400">
46
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
47
{{ $icon := "ban" }}
48
{{ $state := "closed" }}
49
{{ if .Open }}
50
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
51
{{ $icon = "circle-dot" }}
52
{{ $state = "open" }}
53
{{ end }}
54
55
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
56
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
57
+
<span class="text-white dark:text-white">{{ $state }}</span>
58
</span>
59
60
<span>
···
73
{{ if eq .Metadata.CommentCount 1 }}
74
{{ $s = "" }}
75
{{ end }}
76
+
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a>
77
</span>
78
</p>
79
</div>
80
{{ end }}
81
+
</div>
82
+
83
+
{{ block "pagination" . }} {{ end }}
84
+
85
+
{{ end }}
86
+
87
+
{{ define "pagination" }}
88
+
<div class="flex justify-end mt-4 gap-2">
89
+
{{ $currentState := "closed" }}
90
+
{{ if .FilteringByOpen }}
91
+
{{ $currentState = "open" }}
92
+
{{ end }}
93
+
94
+
{{ if gt .Page.Offset 0 }}
95
+
{{ $prev := .Page.Previous }}
96
+
<a
97
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
98
+
hx-boost="true"
99
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
100
+
>
101
+
{{ i "chevron-left" "w-4 h-4" }}
102
+
previous
103
+
</a>
104
+
{{ else }}
105
+
<div></div>
106
+
{{ end }}
107
+
108
+
{{ if eq (len .Issues) .Page.Limit }}
109
+
{{ $next := .Page.Next }}
110
+
<a
111
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
112
+
hx-boost="true"
113
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
114
+
>
115
+
next
116
+
{{ i "chevron-right" "w-4 h-4" }}
117
+
</a>
118
+
{{ end }}
119
</div>
120
{{ end }}
+1
-1
appview/pages/templates/repo/issues/new.html
+1
-1
appview/pages/templates/repo/issues/new.html
+131
-136
appview/pages/templates/repo/log.html
+131
-136
appview/pages/templates/repo/log.html
···
1
{{ define "title" }}commits · {{ .RepoInfo.FullName }}{{ end }}
2
3
{{ define "repoContent" }}
4
-
<section id="commit-message">
5
-
{{ $commit := index .Commits 0 }}
6
-
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
7
-
<div>
8
-
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}">
9
-
<p class="pb-5">{{ index $messageParts 0 }}</p>
10
-
{{ if gt (len $messageParts) 1 }}
11
-
<p class="mt-1 text-sm cursor-text pb-5">
12
-
{{ nl2br (unwrapText (index $messageParts 1)) }}
13
-
</p>
14
-
{{ end }}
15
-
</a>
16
-
</div>
17
18
-
<div class="text-sm text-gray-500">
19
-
<span class="font-mono">
20
-
<a
21
-
href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}"
22
-
class="text-gray-500 no-underline hover:underline"
23
-
>{{ slice $commit.Hash.String }}</a
24
-
>
25
-
</span>
26
-
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
27
-
<span>
28
-
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
29
-
{{ if $didOrHandle }}
30
-
<a
31
-
href="/{{ $didOrHandle }}"
32
-
class="text-gray-500 no-underline hover:underline"
33
-
>{{ $didOrHandle }}</a
34
-
>
35
-
{{ else }}
36
-
<a
37
-
href="mailto:{{ $commit.Author.Email }}"
38
-
class="text-gray-500 no-underline hover:underline"
39
-
>{{ $commit.Author.Name }}</a
40
-
>
41
{{ end }}
42
-
</span>
43
-
<div
44
-
class="inline-block px-1 select-none after:content-['ยท']"
45
-
></div>
46
-
<span>{{ timeFmt $commit.Author.When }}</span>
47
-
</div>
48
-
</section>
49
-
{{ end }}
50
51
-
{{ define "repoAfter" }}
52
-
<main>
53
-
<div id="commit-log" class="flex-1 relative">
54
-
<div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300"></div>
55
-
{{ $end := length .Commits }}
56
-
{{ $commits := subslice .Commits 1 $end }}
57
-
{{ range $commits }}
58
-
<div class="flex flex-row justify-between items-center">
59
-
<div
60
-
class="relative w-full px-4 py-4 mt-4 rounded-sm bg-white"
61
-
>
62
-
<div id="commit-message">
63
-
{{ $messageParts := splitN .Message "\n\n" 2 }}
64
-
<div class="text-base cursor-pointer">
65
-
<div>
66
-
<div>
67
-
<a
68
-
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
69
-
class="inline no-underline hover:underline"
70
-
>{{ index $messageParts 0 }}</a
71
-
>
72
{{ if gt (len $messageParts) 1 }}
73
-
74
-
<button
75
-
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded"
76
-
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"
77
-
>
78
-
{{ i "ellipsis" "w-3 h-3" }}
79
</button>
80
{{ end }}
81
</div>
82
{{ if gt (len $messageParts) 1 }}
83
-
<p
84
-
class="hidden mt-1 text-sm cursor-text pb-2"
85
-
>
86
-
{{ nl2br (unwrapText (index $messageParts 1)) }}
87
</p>
88
{{ end }}
89
</div>
90
</div>
91
</div>
92
93
-
<div class="text-sm text-gray-500 mt-3">
94
-
<span class="font-mono">
95
-
<a
96
-
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
97
-
class="text-gray-500 no-underline hover:underline"
98
-
>{{ slice .Hash.String 0 8 }}</a
99
-
>
100
-
</span>
101
-
<span
102
-
class="mx-2 before:content-['ยท'] before:select-none"
103
-
></span>
104
-
<span>
105
-
{{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }}
106
-
{{ if $didOrHandle }}
107
-
<a
108
-
href="/{{ $didOrHandle }}"
109
-
class="text-gray-500 no-underline hover:underline"
110
-
>{{ $didOrHandle }}</a
111
-
>
112
-
{{ else }}
113
-
<a
114
-
href="mailto:{{ .Author.Email }}"
115
-
class="text-gray-500 no-underline hover:underline"
116
-
>{{ .Author.Name }}</a
117
-
>
118
-
{{ end }}
119
-
</span>
120
-
<div
121
-
class="inline-block px-1 select-none after:content-['ยท']"
122
-
></div>
123
-
<span>{{ timeFmt .Author.When }}</span>
124
-
</div>
125
-
</div>
126
</div>
127
-
{{ end }}
128
-
</div>
129
130
-
{{ $commits_len := len .Commits }}
131
-
<div class="flex justify-end mt-4 gap-2">
132
-
{{ if gt .Page 1 }}
133
-
<a
134
-
class="btn flex items-center gap-2 no-underline hover:no-underline"
135
-
hx-boost="true"
136
-
onclick="window.location.href = window.location.pathname + '?page={{ sub .Page 1 }}'"
137
-
>
138
-
{{ i "chevron-left" "w-4 h-4" }}
139
-
previous
140
-
</a>
141
-
{{ else }}
142
-
<div></div>
143
-
{{ end }}
144
145
-
{{ if eq $commits_len 30 }}
146
-
<a
147
-
class="btn flex items-center gap-2 no-underline hover:no-underline"
148
-
hx-boost="true"
149
-
onclick="window.location.href = window.location.pathname + '?page={{ add .Page 1 }}'"
150
-
>
151
-
next
152
-
{{ i "chevron-right" "w-4 h-4" }}
153
-
</a>
154
-
{{ end }}
155
-
</div>
156
-
</main>
157
{{ end }}
···
1
{{ define "title" }}commits · {{ .RepoInfo.FullName }}{{ end }}
2
3
{{ define "repoContent" }}
4
+
<section id="commit-table" class="overflow-x-auto">
5
+
<h2 class="font-bold text-sm mb-4 uppercase dark:text-white">
6
+
commits
7
+
</h2>
8
9
+
<!-- desktop view (hidden on small screens) -->
10
+
<table class="w-full border-collapse hidden md:table">
11
+
<thead>
12
+
<tr>
13
+
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Author</th>
14
+
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Commit</th>
15
+
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Message</th>
16
+
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Date</th>
17
+
</tr>
18
+
</thead>
19
+
<tbody>
20
+
{{ range $index, $commit := .Commits }}
21
+
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
22
+
<tr class="{{ if ne $index (sub (len $.Commits) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}">
23
+
<td class=" py-3 align-top">
24
+
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
25
+
{{ if $didOrHandle }}
26
+
<a href="/{{ $didOrHandle }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $didOrHandle }}</a>
27
+
{{ else }}
28
+
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
29
+
{{ end }}
30
+
</td>
31
+
<td class=" py-3 align-top font-mono flex items-end">
32
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ slice $commit.Hash.String 0 8 }}</a>
33
+
<div class="inline-flex">
34
+
<button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
35
+
title="Copy SHA"
36
+
onclick="navigator.clipboard.writeText('{{ $commit.Hash.String }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)">
37
+
{{ i "copy" "w-4 h-4" }}
38
+
</button>
39
+
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}" class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" title="Browse repository at this commit">
40
+
{{ i "folder-code" "w-4 h-4" }}
41
+
</a>
42
+
</div>
43
+
</td>
44
+
<td class=" py-3 align-top">
45
+
<div>
46
+
<div class="flex items-center justify-start">
47
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a>
48
+
{{ if gt (len $messageParts) 1 }}
49
+
<button class="ml-2 py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button>
50
+
{{ end }}
51
+
52
+
53
+
{{ if index $.TagMap $commit.Hash.String }}
54
+
{{ range $tag := index $.TagMap $commit.Hash.String }}
55
+
<span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center">
56
+
{{ $tag }}
57
+
</span>
58
+
{{ end }}
59
+
{{ end }}
60
+
61
+
</div>
62
+
63
+
{{ if gt (len $messageParts) 1 }}
64
+
<p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p>
65
+
{{ end }}
66
+
</td>
67
+
<td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ timeFmt $commit.Author.When }}</td>
68
+
</tr>
69
{{ end }}
70
+
</tbody>
71
+
</table>
72
73
+
<!-- mobile view (visible only on small screens) -->
74
+
<div class="md:hidden">
75
+
{{ range $index, $commit := .Commits }}
76
+
<div class="relative p-2 {{ if ne $index (sub (len $.Commits) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}">
77
+
<div id="commit-message">
78
+
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
79
+
<div class="text-base cursor-pointer">
80
+
<div>
81
+
<div class="flex items-center justify-between">
82
+
<div class="flex-1">
83
+
<div class="inline">
84
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}"
85
+
class="inline no-underline hover:underline dark:text-white">
86
+
{{ index $messageParts 0 }}
87
+
</a>
88
{{ if gt (len $messageParts) 1 }}
89
+
<button
90
+
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600 ml-2"
91
+
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">
92
+
{{ i "ellipsis" "w-3 h-3" }}
93
</button>
94
{{ end }}
95
+
96
+
{{ if index $.TagMap $commit.Hash.String }}
97
+
{{ range $tag := index $.TagMap $commit.Hash.String }}
98
+
<span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center">
99
+
{{ $tag }}
100
+
</span>
101
+
{{ end }}
102
+
{{ end }}
103
</div>
104
+
105
{{ if gt (len $messageParts) 1 }}
106
+
<p class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300">
107
+
{{ nl2br (index $messageParts 1) }}
108
</p>
109
{{ end }}
110
</div>
111
+
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}"
112
+
class="p-1 mr-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
113
+
title="Browse repository at this commit">
114
+
{{ i "folder-code" "w-4 h-4" }}
115
+
</a>
116
</div>
117
</div>
118
+
</div>
119
+
</div>
120
121
+
<div class="text-xs text-gray-500 dark:text-gray-400">
122
+
<span class="font-mono">
123
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}"
124
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline">
125
+
{{ slice $commit.Hash.String 0 8 }}
126
+
</a>
127
+
</span>
128
+
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
129
+
<span>
130
+
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
131
+
<a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
132
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline">
133
+
{{ if $didOrHandle }}{{ $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }}
134
+
</a>
135
+
</span>
136
+
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
137
+
<span>{{ shortTimeFmt $commit.Author.When }}</span>
138
</div>
139
+
</div>
140
+
{{ end }}
141
+
</div>
142
+
</section>
143
144
+
{{ end }}
145
146
+
{{ define "repoAfter" }}
147
+
{{ $commits_len := len .Commits }}
148
+
<div class="flex justify-end mt-4 gap-2">
149
+
{{ if gt .Page 1 }}<a class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" hx-boost="true" onclick="window.location.href = window.location.pathname + '?page={{ sub .Page 1 }}'">{{ i "chevron-left" "w-4 h-4" }} previous</a>{{ else }}<div></div>{{ end }}
150
+
{{ if eq $commits_len 60 }}<a class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" hx-boost="true" onclick="window.location.href = window.location.pathname + '?page={{ add .Page 1 }}'">next {{ i "chevron-right" "w-4 h-4" }}</a>{{ end }}
151
+
</div>
152
{{ end }}
+20
-15
appview/pages/templates/repo/new.html
+20
-15
appview/pages/templates/repo/new.html
···
2
3
{{ define "content" }}
4
<div class="p-6">
5
-
<p class="text-xl font-bold">Create a new repository</p>
6
</div>
7
-
<div class="p-6 bg-white drop-shadow-sm rounded">
8
-
<form hx-post="/repo/new" class="space-y-12" hx-swap="none">
9
<div class="space-y-2">
10
-
<label for="name" class="-mb-1">Repository name</label>
11
<input
12
type="text"
13
id="name"
14
name="name"
15
required
16
-
class="w-full max-w-md"
17
/>
18
-
<p class="text-sm text-gray-500">All repositories are publicly visible.</p>
19
20
-
<label for="branch">Default branch</label>
21
<input
22
type="text"
23
id="branch"
24
name="branch"
25
value="main"
26
required
27
-
class="w-full max-w-md"
28
/>
29
30
-
<label for="description">Description</label>
31
<input
32
type="text"
33
id="description"
34
name="description"
35
-
class="w-full max-w-md"
36
/>
37
</div>
38
39
<fieldset class="space-y-3">
40
-
<legend>Select a knot</legend>
41
<div class="space-y-2">
42
<div class="flex flex-col">
43
{{ range .Knots }}
···
49
class="mr-2"
50
id="domain-{{ . }}"
51
/>
52
-
<span>{{ . }}</span>
53
</div>
54
{{ else }}
55
-
<p>No knots available.</p>
56
{{ end }}
57
</div>
58
</div>
59
-
<p class="text-sm text-gray-500">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p>
60
</fieldset>
61
62
<div class="space-y-2">
63
-
<button type="submit" class="btn">create repo</button>
64
<div id="repo" class="error"></div>
65
</div>
66
</form>
···
2
3
{{ define "content" }}
4
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Create a new repository</p>
6
</div>
7
+
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
+
<form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
<div class="space-y-2">
10
+
<label for="name" class="-mb-1 dark:text-white">Repository name</label>
11
<input
12
type="text"
13
id="name"
14
name="name"
15
required
16
+
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
17
/>
18
+
<p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p>
19
20
+
<label for="branch" class="dark:text-white">Default branch</label>
21
<input
22
type="text"
23
id="branch"
24
name="branch"
25
value="main"
26
required
27
+
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
28
/>
29
30
+
<label for="description" class="dark:text-white">Description</label>
31
<input
32
type="text"
33
id="description"
34
name="description"
35
+
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
36
/>
37
</div>
38
39
<fieldset class="space-y-3">
40
+
<legend class="dark:text-white">Select a knot</legend>
41
<div class="space-y-2">
42
<div class="flex flex-col">
43
{{ range .Knots }}
···
49
class="mr-2"
50
id="domain-{{ . }}"
51
/>
52
+
<span class="dark:text-white">{{ . }}</span>
53
</div>
54
{{ else }}
55
+
<p class="dark:text-white">No knots available.</p>
56
{{ end }}
57
</div>
58
</div>
59
+
<p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p>
60
</fieldset>
61
62
<div class="space-y-2">
63
+
<button type="submit" class="btn flex gap-2 items-center">
64
+
create repo
65
+
<span id="spinner" class="group">
66
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
67
+
</span>
68
+
</button>
69
<div id="repo" class="error"></div>
70
</div>
71
</form>
+95
appview/pages/templates/repo/pulls/fragments/pullActions.html
+95
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
···
1
+
{{ define "repo/pulls/fragments/pullActions" }}
2
+
{{ $lastIdx := sub (len .Pull.Submissions) 1 }}
3
+
{{ $roundNumber := .RoundNumber }}
4
+
5
+
{{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }}
6
+
{{ $isMerged := .Pull.State.IsMerged }}
7
+
{{ $isClosed := .Pull.State.IsClosed }}
8
+
{{ $isOpen := .Pull.State.IsOpen }}
9
+
{{ $isConflicted := and .MergeCheck (or .MergeCheck.Error .MergeCheck.IsConflicted) }}
10
+
{{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }}
11
+
{{ $isLastRound := eq $roundNumber $lastIdx }}
12
+
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
13
+
{{ $isUpToDate := .ResubmitCheck.No }}
14
+
<div class="relative w-fit">
15
+
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
16
+
<button
17
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
18
+
hx-target="#actions-{{$roundNumber}}"
19
+
hx-swap="outerHtml"
20
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
21
+
{{ i "message-square-plus" "w-4 h-4" }}
22
+
<span>comment</span>
23
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
24
+
</button>
25
+
{{ if and $isPushAllowed $isOpen $isLastRound }}
26
+
{{ $disabled := "" }}
27
+
{{ if $isConflicted }}
28
+
{{ $disabled = "disabled" }}
29
+
{{ end }}
30
+
<button
31
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
32
+
hx-swap="none"
33
+
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
34
+
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
35
+
{{ i "git-merge" "w-4 h-4" }}
36
+
<span>merge</span>
37
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
38
+
</button>
39
+
{{ end }}
40
+
41
+
{{ if and $isPullAuthor $isOpen $isLastRound }}
42
+
{{ $disabled := "" }}
43
+
{{ if $isUpToDate }}
44
+
{{ $disabled = "disabled" }}
45
+
{{ end }}
46
+
<button id="resubmitBtn"
47
+
{{ if not .Pull.IsPatchBased }}
48
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
49
+
{{ else }}
50
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
51
+
hx-target="#actions-{{$roundNumber}}"
52
+
hx-swap="outerHtml"
53
+
{{ end }}
54
+
55
+
hx-disabled-elt="#resubmitBtn"
56
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
57
+
58
+
{{ if $disabled }}
59
+
title="Update this branch to resubmit this pull request"
60
+
{{ else }}
61
+
title="Resubmit this pull request"
62
+
{{ end }}
63
+
>
64
+
{{ i "rotate-ccw" "w-4 h-4" }}
65
+
<span>resubmit</span>
66
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
67
+
</button>
68
+
{{ end }}
69
+
70
+
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
71
+
<button
72
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
73
+
hx-swap="none"
74
+
class="btn p-2 flex items-center gap-2 group">
75
+
{{ i "ban" "w-4 h-4" }}
76
+
<span>close</span>
77
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
78
+
</button>
79
+
{{ end }}
80
+
81
+
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
82
+
<button
83
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
84
+
hx-swap="none"
85
+
class="btn p-2 flex items-center gap-2 group">
86
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
87
+
<span>reopen</span>
88
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
89
+
</button>
90
+
{{ end }}
91
+
</div>
92
+
</div>
93
+
{{ end }}
94
+
95
+
+25
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
+25
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
···
···
1
+
{{ define "repo/pulls/fragments/pullCompareBranches" }}
2
+
<div id="patch-upload">
3
+
<label for="targetBranch" class="dark:text-white"
4
+
>select a branch</label
5
+
>
6
+
<div class="flex flex-wrap gap-2 items-center">
7
+
<select
8
+
name="sourceBranch"
9
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
10
+
>
11
+
<option disabled selected>source branch</option>
12
+
{{ range .Branches }}
13
+
<option value="{{ .Reference.Name }}" class="py-1">
14
+
{{ .Reference.Name }}
15
+
</option>
16
+
{{ end }}
17
+
</select>
18
+
</div>
19
+
</div>
20
+
21
+
<p class="mt-4">
22
+
Title and description are optional; if left out, they will be extracted
23
+
from the first commit.
24
+
</p>
25
+
{{ end }}
+46
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
+46
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
···
···
1
+
{{ define "repo/pulls/fragments/pullCompareForks" }}
2
+
<div id="patch-upload">
3
+
<label for="forkSelect" class="dark:text-white"
4
+
>select a fork to compare</label
5
+
>
6
+
<div class="flex flex-wrap gap-4 items-center mb-4">
7
+
<div class="flex flex-wrap gap-2 items-center">
8
+
<select
9
+
id="forkSelect"
10
+
name="fork"
11
+
required
12
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
13
+
hx-get="/{{ $.RepoInfo.FullName }}/pulls/new/fork-branches"
14
+
hx-target="#branch-selection"
15
+
hx-vals='{"fork": this.value}'
16
+
hx-swap="innerHTML"
17
+
onchange="document.getElementById('hiddenForkInput').value = this.value;"
18
+
>
19
+
<option disabled selected>select a fork</option>
20
+
{{ range .Forks }}
21
+
<option value="{{ .Name }}" class="py-1">
22
+
{{ .Name }}
23
+
</option>
24
+
{{ end }}
25
+
</select>
26
+
27
+
<input
28
+
type="hidden"
29
+
id="hiddenForkInput"
30
+
name="fork"
31
+
value=""
32
+
/>
33
+
</div>
34
+
35
+
<div id="branch-selection">
36
+
<div class="text-sm text-gray-500 dark:text-gray-400">
37
+
Select a fork first to view available branches
38
+
</div>
39
+
</div>
40
+
</div>
41
+
</div>
42
+
<p class="mt-4">
43
+
Title and description are optional; if left out, they will be extracted
44
+
from the first commit.
45
+
</p>
46
+
{{ end }}
+15
appview/pages/templates/repo/pulls/fragments/pullCompareForksBranches.html
+15
appview/pages/templates/repo/pulls/fragments/pullCompareForksBranches.html
···
···
1
+
{{ define "repo/pulls/fragments/pullCompareForksBranches" }}
2
+
<div class="flex flex-wrap gap-2 items-center">
3
+
<select
4
+
name="sourceBranch"
5
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
6
+
>
7
+
<option disabled selected>source branch</option>
8
+
{{ range .SourceBranches }}
9
+
<option value="{{ .Reference.Name }}" class="py-1">
10
+
{{ .Reference.Name }}
11
+
</option>
12
+
{{ end }}
13
+
</select>
14
+
</div>
15
+
{{ end }}
+68
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+68
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
···
1
+
{{ define "repo/pulls/fragments/pullHeader" }}
2
+
<header class="pb-4">
3
+
<h1 class="text-2xl dark:text-white">
4
+
{{ .Pull.Title }}
5
+
<span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span>
6
+
</h1>
7
+
</header>
8
+
9
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
10
+
{{ $icon := "ban" }}
11
+
12
+
{{ if .Pull.State.IsOpen }}
13
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
14
+
{{ $icon = "git-pull-request" }}
15
+
{{ else if .Pull.State.IsMerged }}
16
+
{{ $bgColor = "bg-purple-600 dark:bg-purple-700" }}
17
+
{{ $icon = "git-merge" }}
18
+
{{ end }}
19
+
20
+
<section class="mt-2">
21
+
<div class="flex items-center gap-2">
22
+
<div
23
+
id="state"
24
+
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"
25
+
>
26
+
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
27
+
<span class="text-white">{{ .Pull.State.String }}</span>
28
+
</div>
29
+
<span class="text-gray-500 dark:text-gray-400 text-sm">
30
+
opened by
31
+
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
32
+
<a href="/{{ $owner }}" class="no-underline hover:underline"
33
+
>{{ $owner }}</a
34
+
>
35
+
<span class="select-none before:content-['\00B7']"></span>
36
+
<time>{{ .Pull.Created | timeFmt }}</time>
37
+
<span class="select-none before:content-['\00B7']"></span>
38
+
<span>
39
+
targeting
40
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
41
+
<a href="/{{ .RepoInfo.FullName }}/tree/{{ .Pull.TargetBranch }}" class="no-underline hover:underline">{{ .Pull.TargetBranch }}</a>
42
+
</span>
43
+
</span>
44
+
{{ if not .Pull.IsPatchBased }}
45
+
from
46
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
47
+
{{ if .Pull.IsForkBased }}
48
+
{{ if .Pull.PullSource.Repo }}
49
+
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>:
50
+
{{- else -}}
51
+
<span class="italic">[deleted fork]</span>
52
+
{{- end -}}
53
+
{{- end -}}
54
+
{{- .Pull.PullSource.Branch -}}
55
+
</span>
56
+
{{ end }}
57
+
</span>
58
+
</div>
59
+
60
+
{{ if .Pull.Body }}
61
+
<article id="body" class="mt-8 prose dark:prose-invert">
62
+
{{ .Pull.Body | markdown }}
63
+
</article>
64
+
{{ end }}
65
+
</section>
66
+
67
+
68
+
{{ end }}
+32
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
+32
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
···
1
+
{{ define "repo/pulls/fragments/pullNewComment" }}
2
+
<div
3
+
id="pull-comment-card-{{ .RoundNumber }}"
4
+
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
+
<div class="text-sm text-gray-500 dark:text-gray-400">
6
+
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
7
+
</div>
8
+
<form
9
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
10
+
hx-swap="none"
11
+
class="w-full flex flex-wrap gap-2">
12
+
<textarea
13
+
name="body"
14
+
class="w-full p-2 rounded border border-gray-200"
15
+
placeholder="Add to the discussion..."></textarea>
16
+
<button type="submit" class="btn flex items-center gap-2">
17
+
{{ i "message-square" "w-4 h-4" }} comment
18
+
</button>
19
+
<button
20
+
type="button"
21
+
class="btn flex items-center gap-2"
22
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions"
23
+
hx-swap="outerHTML"
24
+
hx-target="#pull-comment-card-{{ .RoundNumber }}">
25
+
{{ i "x" "w-4 h-4" }}
26
+
<span>cancel</span>
27
+
</button>
28
+
<div id="pull-comment"></div>
29
+
</form>
30
+
</div>
31
+
{{ end }}
32
+
+21
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
+21
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
···
···
1
+
{{ define "repo/pulls/fragments/pullPatchUpload" }}
2
+
<div id="patch-upload">
3
+
<p>
4
+
You can paste a <code>git diff</code> or a
5
+
<code>git format-patch</code> patch series here.
6
+
</p>
7
+
<textarea
8
+
hx-trigger="keyup changed delay:500ms, paste delay:500ms"
9
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/new/validate-patch"
10
+
hx-swap="none"
11
+
name="patch"
12
+
id="patch"
13
+
rows="12"
14
+
class="w-full mt-2 resize-y font-mono dark:bg-gray-700 dark:text-white dark:border-gray-600"
15
+
placeholder="diff --git a/file.txt b/file.txt
16
+
index 1234567..abcdefg 100644
17
+
--- a/file.txt
18
+
+++ b/file.txt"
19
+
></textarea>
20
+
</div>
21
+
{{ end }}
+52
appview/pages/templates/repo/pulls/fragments/pullResubmit.html
+52
appview/pages/templates/repo/pulls/fragments/pullResubmit.html
···
···
1
+
{{ define "repo/pulls/fragments/pullResubmit" }}
2
+
<div
3
+
id="resubmit-pull-card"
4
+
class="rounded relative border bg-amber-50 dark:bg-amber-900 border-amber-200 dark:border-amber-500 px-6 py-2">
5
+
6
+
<div class="flex items-center gap-2 text-amber-500 dark:text-amber-50">
7
+
{{ i "pencil" "w-4 h-4" }}
8
+
<span class="font-medium">resubmit your patch</span>
9
+
</div>
10
+
11
+
<div class="mt-2 text-sm text-gray-700 dark:text-gray-200">
12
+
You can update this patch to address any reviews.
13
+
This will begin a new round of reviews,
14
+
but you'll still be able to view your previous submissions and feedback.
15
+
</div>
16
+
17
+
<div class="mt-4 flex flex-col">
18
+
<form
19
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
20
+
hx-swap="none"
21
+
class="w-full flex flex-wrap gap-2">
22
+
<textarea
23
+
name="patch"
24
+
class="w-full p-2 mb-2"
25
+
placeholder="Paste your updated patch here."
26
+
rows="15"
27
+
>{{.Pull.LatestPatch}}</textarea>
28
+
<button
29
+
type="submit"
30
+
class="btn flex items-center gap-2"
31
+
{{ if or .Pull.State.IsClosed }}
32
+
disabled
33
+
{{ end }}>
34
+
{{ i "rotate-ccw" "w-4 h-4" }}
35
+
<span>resubmit</span>
36
+
</button>
37
+
<button
38
+
type="button"
39
+
class="btn flex items-center gap-2"
40
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions"
41
+
hx-swap="outerHTML"
42
+
hx-target="#resubmit-pull-card">
43
+
{{ i "x" "w-4 h-4" }}
44
+
<span>cancel</span>
45
+
</button>
46
+
</form>
47
+
48
+
<div id="resubmit-error" class="error"></div>
49
+
<div id="resubmit-success" class="success"></div>
50
+
</div>
51
+
</div>
52
+
{{ end }}
+25
appview/pages/templates/repo/pulls/interdiff.html
+25
appview/pages/templates/repo/pulls/interdiff.html
···
···
1
+
{{ define "title" }}
2
+
interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}; pull #{{ .Pull.PullId }} · {{ .RepoInfo.FullName }}
3
+
{{ end }}
4
+
5
+
{{ define "content" }}
6
+
<section class="rounded drop-shadow-sm bg-white dark:bg-gray-800 py-4 px-6 dark:text-white">
7
+
<header class="pb-2">
8
+
<div class="flex gap-3 items-center mb-3">
9
+
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
10
+
{{ i "arrow-left" "w-5 h-5" }}
11
+
back
12
+
</a>
13
+
<span class="select-none before:content-['\00B7']"></span>
14
+
interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}
15
+
</div>
16
+
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
17
+
{{ template "repo/pulls/fragments/pullHeader" . }}
18
+
</header>
19
+
</section>
20
+
21
+
<section>
22
+
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }}
23
+
</section>
24
+
{{ end }}
25
+
+85
-39
appview/pages/templates/repo/pulls/new.html
+85
-39
appview/pages/templates/repo/pulls/new.html
···
1
-
{{ define "title" }}new pull | {{ .RepoInfo.FullName }}{{ end }}
2
3
{{ define "repoContent" }}
4
-
<section class="prose">
5
-
<p>
6
-
This is v1 of the pull request flow. Paste your patch in the form below.
7
-
Here are the steps to get you started:
8
-
<ul class="list-decimal pl-10 space-y-2 text-gray-700">
9
-
<li class="leading-relaxed">Clone this repository.</li>
10
-
<li class="leading-relaxed">Make your changes in your local repository.</li>
11
-
<li class="leading-relaxed">Grab the diff using <code class="bg-gray-100 px-1 py-0.5 rounded text-gray-800 font-mono text-sm">git diff</code>.</li>
12
-
<li class="leading-relaxed">Paste the diff output in the form below.</li>
13
-
</ul>
14
-
</p>
15
-
</section>
16
<form
17
hx-post="/{{ .RepoInfo.FullName }}/pulls/new"
18
class="mt-6 space-y-6"
19
hx-swap="none"
20
>
21
<div class="flex flex-col gap-4">
22
-
<div>
23
-
<label for="title">write a title</label>
24
-
<input type="text" name="title" id="title" class="w-full" />
25
26
-
<label for="targetBranch">select a target branch</label>
27
-
<p class="text-gray-500">
28
-
The branch you want to make your change against.
29
-
</p>
30
<select
31
name="targetBranch"
32
-
class="p-1 mb-2 border border-gray-200 bg-white"
33
>
34
-
<option disabled selected>select a branch</option>
35
{{ range .Branches }}
36
-
<option value="{{ .Reference.Name }}" class="py-1">
37
{{ .Reference.Name }}
38
</option>
39
{{ end }}
40
</select>
41
-
<label for="body">add a description</label>
42
<textarea
43
name="body"
44
id="body"
45
rows="6"
46
-
class="w-full resize-y"
47
placeholder="Describe your change. Markdown is supported."
48
></textarea>
49
50
-
<div class="mt-4">
51
-
<label for="patch">paste your patch here</label>
52
-
<textarea
53
-
name="patch"
54
-
id="patch"
55
-
rows="10"
56
-
class="w-full resize-y font-mono"
57
-
placeholder="Paste your git diff output here."
58
-
></textarea>
59
-
</div>
60
-
</div>
61
-
<div>
62
-
<button type="submit" class="btn">create</button>
63
</div>
64
</div>
65
-
<div id="pull" class="error"></div>
66
</form>
67
{{ end }}
···
1
+
{{ define "title" }}new pull · {{ .RepoInfo.FullName }}{{ end }}
2
3
{{ define "repoContent" }}
4
<form
5
hx-post="/{{ .RepoInfo.FullName }}/pulls/new"
6
class="mt-6 space-y-6"
7
hx-swap="none"
8
>
9
<div class="flex flex-col gap-4">
10
+
<label>configure your pull request</label>
11
12
+
<p>First, choose a target branch on {{ .RepoInfo.FullName }}.</p>
13
+
<div class="pb-2">
14
<select
15
+
required
16
name="targetBranch"
17
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
18
>
19
+
<option disabled selected>target branch</option>
20
{{ range .Branches }}
21
+
<option value="{{ .Reference.Name }}" class="py-1" {{if .IsDefault}}selected{{end}}>
22
{{ .Reference.Name }}
23
</option>
24
{{ end }}
25
</select>
26
+
</div>
27
+
28
+
<p>Next, choose a pull strategy.</p>
29
+
<nav class="flex space-x-4 items-end">
30
+
<button
31
+
type="button"
32
+
class="px-3 py-2 pb-2 btn"
33
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload"
34
+
hx-target="#patch-strategy"
35
+
hx-swap="innerHTML"
36
+
>
37
+
paste patch
38
+
</button>
39
+
40
+
{{ if .RepoInfo.Roles.IsPushAllowed }}
41
+
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
42
+
or
43
+
</span>
44
+
<button
45
+
type="button"
46
+
class="px-3 py-2 pb-2 btn"
47
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches"
48
+
hx-target="#patch-strategy"
49
+
hx-swap="innerHTML"
50
+
>
51
+
compare branches
52
+
</button>
53
+
{{ end }}
54
+
55
+
56
+
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
57
+
or
58
+
</span>
59
+
<button
60
+
type="button"
61
+
class="px-3 py-2 pb-2 btn"
62
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks"
63
+
hx-target="#patch-strategy"
64
+
hx-swap="innerHTML"
65
+
>
66
+
compare forks
67
+
</button>
68
+
</nav>
69
+
70
+
<section id="patch-strategy">
71
+
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
72
+
</section>
73
+
74
+
<p id="patch-preview"></p>
75
+
76
+
<div id="patch-error" class="error dark:text-red-300"></div>
77
+
78
+
<div>
79
+
<label for="title" class="dark:text-white">write a title</label>
80
+
81
+
<input
82
+
type="text"
83
+
name="title"
84
+
id="title"
85
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600"
86
+
placeholder="One-line summary of your change."
87
+
/>
88
+
</div>
89
+
90
+
<div>
91
+
<label for="body" class="dark:text-white"
92
+
>add a description</label
93
+
>
94
+
95
<textarea
96
name="body"
97
id="body"
98
rows="6"
99
+
class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600"
100
placeholder="Describe your change. Markdown is supported."
101
></textarea>
102
+
</div>
103
104
+
<div class="flex justify-start items-center gap-2 mt-4">
105
+
<button type="submit" class="btn flex items-center gap-2">
106
+
{{ i "git-pull-request-create" "w-4 h-4" }}
107
+
create pull
108
+
</button>
109
</div>
110
</div>
111
+
<div id="pull" class="error dark:text-red-300"></div>
112
</form>
113
{{ end }}
+22
-83
appview/pages/templates/repo/pulls/patch.html
+22
-83
appview/pages/templates/repo/pulls/patch.html
···
1
{{ define "title" }}
2
-
{{ $oneIndexedRound := add .Round 1 }}
3
-
patch of {{ .Pull.Title }} · round #{{ $oneIndexedRound }} · pull #{{ .Pull.PullId }} · {{ .RepoInfo.FullName }}
4
{{ end }}
5
6
{{ define "content" }}
7
-
{{ $oneIndexedRound := add .Round 1 }}
8
-
{{ $stat := .Diff.Stat }}
9
-
<div class="rounded drop-shadow-sm bg-white py-4 px-6">
10
-
<header class="pb-2">
11
-
<div class="flex gap-3 items-center mb-3">
12
-
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
13
-
{{ i "arrow-left" "w-5 h-5" }}
14
-
back
15
-
</a>
16
-
<span class="select-none before:content-['\00B7']"></span>
17
-
round #{{ $oneIndexedRound }}
18
-
</div>
19
-
<div class="border-t border-gray-200 my-2"></div>
20
-
<h1 class="text-2xl mt-3">
21
-
{{ .Pull.Title }}
22
-
<span class="text-gray-500">#{{ .Pull.PullId }}</span>
23
-
</h1>
24
-
</header>
25
-
26
-
{{ $bgColor := "bg-gray-800" }}
27
-
{{ $icon := "ban" }}
28
-
29
-
{{ if .Pull.State.IsOpen }}
30
-
{{ $bgColor = "bg-green-600" }}
31
-
{{ $icon = "git-pull-request" }}
32
-
{{ else if .Pull.State.IsMerged }}
33
-
{{ $bgColor = "bg-purple-600" }}
34
-
{{ $icon = "git-merge" }}
35
-
{{ end }}
36
-
37
-
<section>
38
-
<div class="flex items-center gap-2">
39
-
<div
40
-
id="state"
41
-
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"
42
-
>
43
-
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
44
-
<span class="text-white">{{ .Pull.State.String }}</span>
45
-
</div>
46
-
<span class="text-gray-500 text-sm">
47
-
opened by
48
-
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
49
-
<a href="/{{ $owner }}" class="no-underline hover:underline"
50
-
>{{ $owner }}</a
51
-
>
52
-
<span class="select-none before:content-['\00B7']"></span>
53
-
<time>{{ .Pull.Created | timeFmt }}</time>
54
-
<span class="select-none before:content-['\00B7']"></span>
55
-
<span>targeting branch
56
-
<span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
57
-
{{ .Pull.TargetBranch }}
58
-
</span>
59
-
</span>
60
-
</span>
61
-
</div>
62
-
63
-
{{ if .Pull.Body }}
64
-
<article id="body" class="mt-2 prose">
65
-
{{ .Pull.Body | markdown }}
66
-
</article>
67
-
{{ end }}
68
-
</section>
69
-
70
-
<div id="diff-stat">
71
-
<br>
72
-
<strong class="text-sm uppercase mb-4">Changed files</strong>
73
-
{{ range .Diff.Diff }}
74
-
<ul>
75
-
{{ if .IsDelete }}
76
-
<li><a href="#file-{{ .Name.Old }}">{{ .Name.Old }}</a></li>
77
-
{{ else }}
78
-
<li><a href="#file-{{ .Name.New }}">{{ .Name.New }}</a></li>
79
-
{{ end }}
80
-
</ul>
81
-
{{ end }}
82
-
</div>
83
-
</div>
84
-
85
-
<section>
86
-
{{ template "fragments/diff" (list .RepoInfo.FullName .Diff) }}
87
-
</section>
88
{{ end }}
···
1
{{ define "title" }}
2
+
patch of {{ .Pull.Title }} · round #{{ .Round }} · pull #{{ .Pull.PullId }} · {{ .RepoInfo.FullName }}
3
{{ end }}
4
5
{{ define "content" }}
6
+
<section>
7
+
<section
8
+
class="bg-white dark:bg-gray-800 p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm dark:text-white"
9
+
>
10
+
<div class="flex gap-3 items-center mb-3">
11
+
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
12
+
{{ i "arrow-left" "w-5 h-5" }}
13
+
back
14
+
</a>
15
+
<span class="select-none before:content-['\00B7']"></span>
16
+
round<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .Round }}</span>
17
+
<span class="select-none before:content-['\00B7']"></span>
18
+
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Round }}.patch">
19
+
view raw
20
+
</a>
21
+
</div>
22
+
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
23
+
{{ template "repo/pulls/fragments/pullHeader" . }}
24
+
</section>
25
+
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
26
+
</section>
27
{{ end }}
+127
-93
appview/pages/templates/repo/pulls/pull.html
+127
-93
appview/pages/templates/repo/pulls/pull.html
···
3
{{ end }}
4
5
{{ define "repoContent" }}
6
-
<header class="pb-4">
7
-
<h1 class="text-2xl">
8
-
{{ .Pull.Title }}
9
-
<span class="text-gray-500">#{{ .Pull.PullId }}</span>
10
-
</h1>
11
-
</header>
12
-
13
-
{{ $bgColor := "bg-gray-800" }}
14
-
{{ $icon := "ban" }}
15
-
16
-
{{ if .Pull.State.IsOpen }}
17
-
{{ $bgColor = "bg-green-600" }}
18
-
{{ $icon = "git-pull-request" }}
19
-
{{ else if .Pull.State.IsMerged }}
20
-
{{ $bgColor = "bg-purple-600" }}
21
-
{{ $icon = "git-merge" }}
22
-
{{ end }}
23
-
24
-
<section>
25
-
<div class="flex items-center gap-2">
26
-
<div
27
-
id="state"
28
-
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"
29
-
>
30
-
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
31
-
<span class="text-white">{{ .Pull.State.String }}</span>
32
-
</div>
33
-
<span class="text-gray-500 text-sm">
34
-
opened by
35
-
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
36
-
<a href="/{{ $owner }}" class="no-underline hover:underline"
37
-
>{{ $owner }}</a
38
-
>
39
-
<span class="select-none before:content-['\00B7']"></span>
40
-
<time>{{ .Pull.Created | timeFmt }}</time>
41
-
<span class="select-none before:content-['\00B7']"></span>
42
-
<span>targeting branch
43
-
<span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
44
-
{{ .Pull.TargetBranch }}
45
-
</span>
46
-
</span>
47
-
</span>
48
-
</div>
49
-
50
-
{{ if .Pull.Body }}
51
-
<article id="body" class="mt-2 prose">
52
-
{{ .Pull.Body | markdown }}
53
-
</article>
54
-
{{ end }}
55
-
</section>
56
-
57
{{ end }}
58
59
{{ define "repoAfter" }}
···
72
{{ $targetBranch := .Pull.TargetBranch }}
73
{{ $repoName := .RepoInfo.FullName }}
74
{{ range $idx, $item := .Pull.Submissions }}
75
-
{{ $diff := $item.AsNiceDiff $targetBranch }}
76
{{ with $item }}
77
-
{{ $oneIndexedRound := add .RoundNumber 1 }}
78
<details {{ if eq $idx $lastIdx }}open{{ end }}>
79
-
<summary id="round-#{{ $oneIndexedRound }}" class="list-none cursor-pointer">
80
<div class="flex flex-wrap gap-2 items-center">
81
<!-- round number -->
82
-
<div class="rounded bg-white drop-shadow-sm px-3 py-2">
83
-
#{{ $oneIndexedRound }}
84
</div>
85
<!-- round summary -->
86
-
<div class="rounded drop-shadow-sm bg-white p-2 text-gray-500">
87
<span>
88
{{ $owner := index $.DidHandleMap $.Pull.OwnerDid }}
89
{{ $re := "re" }}
···
93
<span class="hidden md:inline">{{$re}}submitted</span>
94
by <a href="/{{ $owner }}">{{ $owner }}</a>
95
<span class="select-none before:content-['\00B7']"></span>
96
-
<a class="text-gray-500 hover:text-gray-500" href="#round-#{{ $oneIndexedRound }}"><time>{{ .Created | shortTimeFmt }}</time></a>
97
<span class="select-none before:content-['ยท']"></span>
98
{{ $s := "s" }}
99
{{ if eq (len .Comments) 1 }}
···
102
{{ len .Comments }} comment{{$s}}
103
</span>
104
</div>
105
-
<!-- view patch -->
106
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
107
hx-boost="true"
108
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}">
109
-
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span>
110
</a>
111
</div>
112
</summary>
113
-
<div class="md:pl-12 flex flex-col gap-2 mt-2 relative">
114
-
{{ range .Comments }}
115
-
<div id="comment-{{.ID}}" class="bg-white rounded drop-shadow-sm py-2 px-4 relative w-fit">
116
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
117
-
<div class="text-sm text-gray-500">
118
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
119
<a href="/{{$owner}}">{{$owner}}</a>
120
<span class="before:content-['ยท']"></span>
121
-
<a class="text-gray-500 hover:text-gray-500" href="#comment-{{.ID}}"><time>{{ .Created | shortTimeFmt }}</time></a>
122
</div>
123
-
<div class="prose">
124
-
{{ .Body | markdown }}
125
</div>
126
</div>
127
{{ end }}
128
129
{{ if eq $lastIdx .RoundNumber }}
130
{{ block "mergeStatus" $ }} {{ end }}
131
{{ end }}
132
133
{{ if $.LoggedInUser }}
134
-
{{ template "fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck) }}
135
{{ else }}
136
-
<div class="bg-white rounded drop-shadow-sm px-6 py-4 w-fit">
137
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
138
<a href="/login" class="underline">login</a> to join the discussion
139
</div>
140
{{ end }}
141
</div>
142
</details>
143
-
<hr class="md:hidden"/>
144
{{ end }}
145
{{ end }}
146
{{ end }}
147
148
{{ define "mergeStatus" }}
149
{{ if .Pull.State.IsClosed }}
150
-
<div class="bg-gray-50 border border-black rounded drop-shadow-sm px-6 py-2 relative w-fit">
151
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
152
-
<div class="flex items-center gap-2 text-black">
153
{{ i "ban" "w-4 h-4" }}
154
<span class="font-medium">closed without merging</span
155
>
156
</div>
157
</div>
158
{{ else if .Pull.State.IsMerged }}
159
-
<div class="bg-purple-50 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
160
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
161
-
<div class="flex items-center gap-2 text-purple-500">
162
{{ i "git-merge" "w-4 h-4" }}
163
<span class="font-medium">pull request successfully merged</span
164
>
165
</div>
166
</div>
167
{{ else if and .MergeCheck .MergeCheck.Error }}
168
-
<div class="bg-red-50 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
169
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
170
-
<div class="flex items-center gap-2 text-red-500">
171
{{ i "triangle-alert" "w-4 h-4" }}
172
<span class="font-medium">{{ .MergeCheck.Error }}</span>
173
</div>
174
</div>
175
{{ else if and .MergeCheck .MergeCheck.IsConflicted }}
176
-
<div class="bg-red-50 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
177
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
178
-
<div class="flex items-center gap-2 text-red-500">
179
-
{{ i "triangle-alert" "w-4 h-4" }}
180
-
<span class="font-medium">merge conflicts detected</span>
181
-
<ul class="text-sm space-y-1">
182
{{ range .MergeCheck.Conflicts }}
183
{{ if .Filename }}
184
<li class="flex items-center">
185
-
{{ i "file-warning" "w-3 h-3 mr-1.5 text-red-500" }}
186
<span class="font-mono">{{ slice .Filename 0 (sub (len .Filename) 2) }}</span>
187
</li>
188
{{ end }}
···
191
</div>
192
</div>
193
{{ else if .MergeCheck }}
194
-
<div class="bg-green-50 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
195
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
196
-
<div class="flex items-center gap-2 text-green-500">
197
{{ i "circle-check-big" "w-4 h-4" }}
198
<span class="font-medium">no conflicts, ready to merge</span>
199
</div>
200
</div>
201
{{ end }}
202
{{ end }}
···
3
{{ end }}
4
5
{{ define "repoContent" }}
6
+
{{ template "repo/pulls/fragments/pullHeader" . }}
7
{{ end }}
8
9
{{ define "repoAfter" }}
···
22
{{ $targetBranch := .Pull.TargetBranch }}
23
{{ $repoName := .RepoInfo.FullName }}
24
{{ range $idx, $item := .Pull.Submissions }}
25
{{ with $item }}
26
<details {{ if eq $idx $lastIdx }}open{{ end }}>
27
+
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
28
<div class="flex flex-wrap gap-2 items-center">
29
<!-- round number -->
30
+
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
31
+
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
32
</div>
33
<!-- round summary -->
34
+
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
35
<span>
36
{{ $owner := index $.DidHandleMap $.Pull.OwnerDid }}
37
{{ $re := "re" }}
···
41
<span class="hidden md:inline">{{$re}}submitted</span>
42
by <a href="/{{ $owner }}">{{ $owner }}</a>
43
<span class="select-none before:content-['\00B7']"></span>
44
+
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}"><time>{{ .Created | shortTimeFmt }}</time></a>
45
<span class="select-none before:content-['ยท']"></span>
46
{{ $s := "s" }}
47
{{ if eq (len .Comments) 1 }}
···
50
{{ len .Comments }} comment{{$s}}
51
</span>
52
</div>
53
+
54
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
55
hx-boost="true"
56
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}">
57
+
{{ i "file-diff" "w-4 h-4" }}
58
+
<span class="hidden md:inline">diff</span>
59
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
60
</a>
61
+
{{ if not (eq .RoundNumber 0) }}
62
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
63
+
hx-boost="true"
64
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
65
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
66
+
<span class="hidden md:inline">interdiff</span>
67
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
68
+
</a>
69
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
70
+
{{ end }}
71
</div>
72
</summary>
73
+
74
+
{{ if .IsFormatPatch }}
75
+
{{ $patches := .AsFormatPatch }}
76
+
{{ $round := .RoundNumber }}
77
+
<details class="group py-2 md:ml-[3.5rem] text-gray-500 dark:text-gray-400 flex flex-col gap-2 relative text-sm">
78
+
<summary class="py-1 list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
79
+
{{ $s := "s" }}
80
+
{{ if eq (len $patches) 1 }}
81
+
{{ $s = "" }}
82
+
{{ end }}
83
+
<div class="group-open:hidden flex items-center gap-2 ml-2">
84
+
{{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $patches }} commit{{$s}}
85
+
</div>
86
+
<div class="hidden group-open:flex items-center gap-2 ml-2">
87
+
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $patches }} commit{{$s}}
88
+
</div>
89
+
</summary>
90
+
{{ range $patches }}
91
+
<div id="commit-{{.SHA}}" class="py-1 px-2 relative w-full md:max-w-3/5 md:w-fit flex flex-col">
92
+
<div class="flex items-center gap-2">
93
+
{{ i "git-commit-horizontal" "w-4 h-4" }}
94
+
<div class="text-sm text-gray-500 dark:text-gray-400">
95
+
<!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches -->
96
+
{{ $fullRepo := "" }}
97
+
{{ if and $.Pull.IsForkBased $.Pull.PullSource.Repo }}
98
+
{{ $fullRepo = printf "%s/%s" $owner $.Pull.PullSource.Repo.Name }}
99
+
{{ else if $.Pull.IsBranchBased }}
100
+
{{ $fullRepo = $.RepoInfo.FullName }}
101
+
{{ end }}
102
+
103
+
<!-- if $fullRepo was resolved, link to it, otherwise just span without a link -->
104
+
{{ if $fullRepo }}
105
+
<a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-500 dark:text-gray-400">{{ slice .SHA 0 8 }}</a>
106
+
{{ else }}
107
+
<span class="font-mono">{{ slice .SHA 0 8 }}</span>
108
+
{{ end }}
109
+
</div>
110
+
<div class="flex items-center">
111
+
<span>{{ .Title }}</span>
112
+
{{ if gt (len .Body) 0 }}
113
+
<button
114
+
class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
115
+
hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')"
116
+
>
117
+
{{ i "ellipsis" "w-3 h-3" }}
118
+
</button>
119
+
{{ end }}
120
+
</div>
121
+
</div>
122
+
{{ if gt (len .Body) 0 }}
123
+
<p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 text-sm pb-2">
124
+
{{ nl2br .Body }}
125
+
</p>
126
+
{{ end }}
127
+
</div>
128
+
{{ end }}
129
+
</details>
130
+
{{ end }}
131
+
132
+
133
+
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
134
+
{{ range $cidx, $c := .Comments }}
135
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
136
+
{{ if gt $cidx 0 }}
137
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
138
+
{{ end }}
139
+
<div class="text-sm text-gray-500 dark:text-gray-400">
140
+
{{ $owner := index $.DidHandleMap $c.OwnerDid }}
141
<a href="/{{$owner}}">{{$owner}}</a>
142
<span class="before:content-['ยท']"></span>
143
+
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"><time>{{ $c.Created | shortTimeFmt }}</time></a>
144
</div>
145
+
<div class="prose dark:prose-invert">
146
+
{{ $c.Body | markdown }}
147
</div>
148
</div>
149
{{ end }}
150
151
{{ if eq $lastIdx .RoundNumber }}
152
{{ block "mergeStatus" $ }} {{ end }}
153
+
{{ block "resubmitStatus" $ }} {{ end }}
154
{{ end }}
155
156
{{ if $.LoggedInUser }}
157
+
{{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck) }}
158
{{ else }}
159
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white">
160
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
161
<a href="/login" class="underline">login</a> to join the discussion
162
</div>
163
{{ end }}
164
</div>
165
</details>
166
+
<hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/>
167
{{ end }}
168
{{ end }}
169
{{ end }}
170
171
{{ define "mergeStatus" }}
172
{{ if .Pull.State.IsClosed }}
173
+
<div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
174
+
<div class="flex items-center gap-2 text-black dark:text-white">
175
{{ i "ban" "w-4 h-4" }}
176
<span class="font-medium">closed without merging</span
177
>
178
</div>
179
</div>
180
{{ else if .Pull.State.IsMerged }}
181
+
<div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
182
+
<div class="flex items-center gap-2 text-purple-500 dark:text-purple-300">
183
{{ i "git-merge" "w-4 h-4" }}
184
<span class="font-medium">pull request successfully merged</span
185
>
186
</div>
187
</div>
188
{{ else if and .MergeCheck .MergeCheck.Error }}
189
+
<div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
190
+
<div class="flex items-center gap-2 text-red-500 dark:text-red-300">
191
{{ i "triangle-alert" "w-4 h-4" }}
192
<span class="font-medium">{{ .MergeCheck.Error }}</span>
193
</div>
194
</div>
195
{{ else if and .MergeCheck .MergeCheck.IsConflicted }}
196
+
<div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
197
+
<div class="flex flex-col gap-2 text-red-500 dark:text-red-300">
198
+
<div class="flex items-center gap-2">
199
+
{{ i "triangle-alert" "w-4 h-4" }}
200
+
<span class="font-medium">merge conflicts detected</span>
201
+
</div>
202
+
<ul class="space-y-1">
203
{{ range .MergeCheck.Conflicts }}
204
{{ if .Filename }}
205
<li class="flex items-center">
206
+
{{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }}
207
<span class="font-mono">{{ slice .Filename 0 (sub (len .Filename) 2) }}</span>
208
</li>
209
{{ end }}
···
212
</div>
213
</div>
214
{{ else if .MergeCheck }}
215
+
<div class="bg-green-50 dark:bg-green-900 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
216
+
<div class="flex items-center gap-2 text-green-500 dark:text-green-300">
217
{{ i "circle-check-big" "w-4 h-4" }}
218
<span class="font-medium">no conflicts, ready to merge</span>
219
</div>
220
</div>
221
{{ end }}
222
{{ end }}
223
+
224
+
{{ define "resubmitStatus" }}
225
+
{{ if .ResubmitCheck.Yes }}
226
+
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
227
+
<div class="flex items-center gap-2 text-amber-500 dark:text-amber-300">
228
+
{{ i "triangle-alert" "w-4 h-4" }}
229
+
<span class="font-medium">this branch has been updated, consider resubmitting</span>
230
+
</div>
231
+
</div>
232
+
{{ end }}
233
+
{{ end }}
234
+
235
+
{{ define "commits" }}
236
+
{{ end }}
+67
-30
appview/pages/templates/repo/pulls/pulls.html
+67
-30
appview/pages/templates/repo/pulls/pulls.html
···
2
3
{{ define "repoContent" }}
4
<div class="flex justify-between items-center">
5
-
<p>
6
-
filtering
7
-
<select
8
-
class="border px-1 bg-white border-gray-200"
9
-
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/pulls?state=' + this.value"
10
>
11
-
<option value="open" {{ if .FilteringBy.IsOpen }}selected{{ end }}>
12
-
open ({{ .RepoInfo.Stats.PullCount.Open }})
13
-
</option>
14
-
<option value="merged" {{ if .FilteringBy.IsMerged }}selected{{ end }}>
15
-
merged ({{ .RepoInfo.Stats.PullCount.Merged }})
16
-
</option>
17
-
<option value="closed" {{ if .FilteringBy.IsClosed }}selected{{ end }}>
18
-
closed ({{ .RepoInfo.Stats.PullCount.Closed }})
19
-
</option>
20
-
</select>
21
-
pull requests
22
-
</p>
23
<a
24
href="/{{ .RepoInfo.FullName }}/pulls/new"
25
class="btn text-sm flex items-center gap-2 no-underline hover:no-underline"
26
>
27
-
{{ i "git-pull-request" "w-4 h-4" }}
28
-
<span>new pull request</span>
29
</a>
30
</div>
31
<div class="error" id="pulls"></div>
···
34
{{ define "repoAfter" }}
35
<div class="flex flex-col gap-2 mt-2">
36
{{ range .Pulls }}
37
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4">
38
<div class="pb-2">
39
-
<a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}">
40
{{ .Title }}
41
-
<span class="text-gray-500">#{{ .PullId }}</span>
42
</a>
43
</div>
44
-
<p class="text-sm text-gray-500">
45
-
{{ $bgColor := "bg-gray-800" }}
46
{{ $icon := "ban" }}
47
48
{{ if .State.IsOpen }}
49
-
{{ $bgColor = "bg-green-600" }}
50
{{ $icon = "git-pull-request" }}
51
{{ else if .State.IsMerged }}
52
-
{{ $bgColor = "bg-purple-600" }}
53
{{ $icon = "git-merge" }}
54
{{ end }}
55
···
62
</span>
63
64
<span>
65
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
66
-
<a href="/{{ $owner }}">{{ $owner }}</a>
67
</span>
68
69
<span class="before:content-['ยท']">
···
73
</span>
74
75
<span class="before:content-['ยท']">
76
-
targeting branch
77
-
<span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
78
{{ .TargetBranch }}
79
</span>
80
</span>
81
</p>
82
</div>
···
2
3
{{ define "repoContent" }}
4
<div class="flex justify-between items-center">
5
+
<div class="flex gap-4">
6
+
<a
7
+
href="?state=open"
8
+
class="flex items-center gap-2 {{ if .FilteringBy.IsOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
9
+
>
10
+
{{ i "git-pull-request" "w-4 h-4" }}
11
+
<span>{{ .RepoInfo.Stats.PullCount.Open }} open</span>
12
+
</a>
13
+
<a
14
+
href="?state=merged"
15
+
class="flex items-center gap-2 {{ if .FilteringBy.IsMerged }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
16
+
>
17
+
{{ i "git-merge" "w-4 h-4" }}
18
+
<span>{{ .RepoInfo.Stats.PullCount.Merged }} merged</span>
19
+
</a>
20
+
<a
21
+
href="?state=closed"
22
+
class="flex items-center gap-2 {{ if .FilteringBy.IsClosed }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
23
>
24
+
{{ i "ban" "w-4 h-4" }}
25
+
<span>{{ .RepoInfo.Stats.PullCount.Closed }} closed</span>
26
+
</a>
27
+
</div>
28
<a
29
href="/{{ .RepoInfo.FullName }}/pulls/new"
30
class="btn text-sm flex items-center gap-2 no-underline hover:no-underline"
31
>
32
+
{{ i "git-pull-request-create" "w-4 h-4" }}
33
+
<span>new</span>
34
</a>
35
</div>
36
<div class="error" id="pulls"></div>
···
39
{{ define "repoAfter" }}
40
<div class="flex flex-col gap-2 mt-2">
41
{{ range .Pulls }}
42
+
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 px-6 py-4">
43
<div class="pb-2">
44
+
<a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white">
45
{{ .Title }}
46
+
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
47
</a>
48
</div>
49
+
<p class="text-sm text-gray-500 dark:text-gray-400">
50
+
{{ $owner := index $.DidHandleMap .OwnerDid }}
51
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
52
{{ $icon := "ban" }}
53
54
{{ if .State.IsOpen }}
55
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
56
{{ $icon = "git-pull-request" }}
57
{{ else if .State.IsMerged }}
58
+
{{ $bgColor = "bg-purple-600 dark:bg-purple-700" }}
59
{{ $icon = "git-merge" }}
60
{{ end }}
61
···
68
</span>
69
70
<span>
71
+
<a href="/{{ $owner }}" class="dark:text-gray-300">{{ $owner }}</a>
72
</span>
73
74
<span class="before:content-['ยท']">
···
78
</span>
79
80
<span class="before:content-['ยท']">
81
+
targeting
82
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
83
{{ .TargetBranch }}
84
</span>
85
+
</span>
86
+
{{ if not .IsPatchBased }}
87
+
from
88
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
89
+
{{ if .IsForkBased }}
90
+
{{ if .PullSource.Repo }}
91
+
<a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>:
92
+
{{- else -}}
93
+
<span class="italic">[deleted fork]</span>
94
+
{{- end -}}
95
+
{{- end -}}
96
+
{{- .PullSource.Branch -}}
97
+
</span>
98
+
{{ end }}
99
+
<span class="before:content-['ยท']">
100
+
{{ $latestRound := .LastRoundNumber }}
101
+
{{ $lastSubmission := index .Submissions $latestRound }}
102
+
round
103
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
104
+
#{{ .LastRoundNumber }}
105
+
</span>
106
+
{{ $commentCount := len $lastSubmission.Comments }}
107
+
{{ $s := "s" }}
108
+
{{ if eq $commentCount 1 }}
109
+
{{ $s = "" }}
110
+
{{ end }}
111
+
112
+
{{ if eq $commentCount 0 }}
113
+
awaiting comments
114
+
{{ else }}
115
+
recieved {{ len $lastSubmission.Comments}} comment{{$s}}
116
+
{{ end }}
117
</span>
118
</p>
119
</div>
+52
-8
appview/pages/templates/repo/settings.html
+52
-8
appview/pages/templates/repo/settings.html
···
1
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
{{ define "repoContent" }}
3
-
<header class="font-bold text-sm mb-4 uppercase">Collaborators</header>
4
5
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
6
{{ range .Collaborators }}
7
<div id="collaborator" class="mb-2">
8
<a
9
href="/{{ didOrHandle .Did .Handle }}"
10
-
class="no-underline hover:underline text-black"
11
>
12
{{ didOrHandle .Did .Handle }}
13
</a>
14
<div>
15
-
<span class="text-sm text-gray-500">
16
{{ .Role }}
17
</span>
18
</div>
···
20
{{ end }}
21
</div>
22
23
-
{{ if .IsCollaboratorInviteAllowed }}
24
-
<h3>add collaborator</h3>
25
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator">
26
-
<label for="collaborator">did or handle:</label>
27
-
<input type="text" id="collaborator" name="collaborator" required />
28
-
<button class="btn my-2" type="text">add collaborator</button>
29
</form>
30
{{ end }}
31
{{ end }}
···
1
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
{{ define "repoContent" }}
3
+
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
4
+
Collaborators
5
+
</header>
6
7
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
8
{{ range .Collaborators }}
9
<div id="collaborator" class="mb-2">
10
<a
11
href="/{{ didOrHandle .Did .Handle }}"
12
+
class="no-underline hover:underline text-black dark:text-white"
13
>
14
{{ didOrHandle .Did .Handle }}
15
</a>
16
<div>
17
+
<span class="text-sm text-gray-500 dark:text-gray-400">
18
{{ .Role }}
19
</span>
20
</div>
···
22
{{ end }}
23
</div>
24
25
+
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
26
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator">
27
+
<label for="collaborator" class="dark:text-white"
28
+
>add collaborator</label
29
+
>
30
+
<input
31
+
type="text"
32
+
id="collaborator"
33
+
name="collaborator"
34
+
required
35
+
class="dark:bg-gray-700 dark:text-white"
36
+
placeholder="enter did or handle"
37
+
/>
38
+
<button
39
+
class="btn my-2 dark:text-white dark:hover:bg-gray-700"
40
+
type="text"
41
+
>
42
+
add
43
+
</button>
44
</form>
45
{{ end }}
46
+
47
+
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6">
48
+
<label for="branch">default branch</label>
49
+
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
50
+
{{ range .Branches }}
51
+
<option
52
+
value="{{ . }}"
53
+
class="py-1"
54
+
{{ if eq . $.DefaultBranch }}
55
+
selected
56
+
{{ end }}
57
+
>
58
+
{{ . }}
59
+
</option>
60
+
{{ end }}
61
+
</select>
62
+
<button class="btn my-2" type="text">save</button>
63
+
</form>
64
+
65
+
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
66
+
<form hx-confirm="Are you sure you want to delete this repository?" hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" class="mt-6">
67
+
<label for="branch">delete repository</label>
68
+
<button class="btn my-2" type="text">delete</button>
69
+
<span>
70
+
Deleting a repository is irreversible and permanent.
71
+
</span>
72
+
</form>
73
+
{{ end }}
74
+
75
{{ end }}
+26
-24
appview/pages/templates/repo/tree.html
+26
-24
appview/pages/templates/repo/tree.html
···
17
{{ $containerstyle := "py-1" }}
18
{{ $linkstyle := "no-underline hover:underline" }}
19
20
-
<div class="pb-2 text-base">
21
-
<div class="flex justify-between">
22
-
<div id="breadcrumbs">
23
{{ range .BreadCrumbs }}
24
-
<a href="{{ index . 1}}" class="text-bold text-gray-500 {{ $linkstyle }}">{{ index . 0 }}</a> /
25
{{ end }}
26
</div>
27
-
<div id="dir-info">
28
-
<span class="text-gray-500 text-xs">
29
-
{{ $stats := .TreeStats }}
30
31
-
{{ if eq $stats.NumFolders 1 }}
32
-
{{ $stats.NumFolders }} folder
33
-
<span class="px-1 select-none">ยท</span>
34
-
{{ else if gt $stats.NumFolders 1 }}
35
-
{{ $stats.NumFolders }} folders
36
-
<span class="px-1 select-none">ยท</span>
37
-
{{ end }}
38
39
-
{{ if eq $stats.NumFiles 1 }}
40
-
{{ $stats.NumFiles }} file
41
-
{{ else if gt $stats.NumFiles 1 }}
42
-
{{ $stats.NumFiles }} files
43
-
{{ end }}
44
-
</span>
45
</div>
46
</div>
47
</div>
···
52
<div class="flex justify-between items-center">
53
<a href="/{{ $.BaseTreeLink }}/{{ .Name }}" class="{{ $linkstyle }}">
54
<div class="flex items-center gap-2">
55
-
{{ i "folder" "w-3 h-3 fill-current" }}{{ .Name }}
56
</div>
57
</a>
58
-
<time class="text-xs text-gray-500">{{ timeFmt .LastCommit.When }}</time>
59
</div>
60
</div>
61
{{ end }}
···
67
<div class="flex justify-between items-center">
68
<a href="/{{ $.BaseBlobLink }}/{{ .Name }}" class="{{ $linkstyle }}">
69
<div class="flex items-center gap-2">
70
-
{{ i "file" "w-3 h-3" }}{{ .Name }}
71
</div>
72
</a>
73
-
<time class="text-xs text-gray-500">{{ timeFmt .LastCommit.When }}</time>
74
</div>
75
</div>
76
{{ end }}
···
17
{{ $containerstyle := "py-1" }}
18
{{ $linkstyle := "no-underline hover:underline" }}
19
20
+
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
21
+
<div class="flex flex-col md:flex-row md:justify-between gap-2">
22
+
<div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500">
23
{{ range .BreadCrumbs }}
24
+
<a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ index . 0 }}</a> /
25
{{ end }}
26
</div>
27
+
<div id="dir-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
28
+
{{ $stats := .TreeStats }}
29
30
+
<span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span>
31
+
{{ if eq $stats.NumFolders 1 }}
32
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
33
+
<span>{{ $stats.NumFolders }} folder</span>
34
+
{{ else if gt $stats.NumFolders 1 }}
35
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
36
+
<span>{{ $stats.NumFolders }} folders</span>
37
+
{{ end }}
38
39
+
{{ if eq $stats.NumFiles 1 }}
40
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
41
+
<span>{{ $stats.NumFiles }} file</span>
42
+
{{ else if gt $stats.NumFiles 1 }}
43
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
44
+
<span>{{ $stats.NumFiles }} files</span>
45
+
{{ end }}
46
+
47
</div>
48
</div>
49
</div>
···
54
<div class="flex justify-between items-center">
55
<a href="/{{ $.BaseTreeLink }}/{{ .Name }}" class="{{ $linkstyle }}">
56
<div class="flex items-center gap-2">
57
+
{{ i "folder" "size-4 fill-current" }}{{ .Name }}
58
</div>
59
</a>
60
+
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time>
61
</div>
62
</div>
63
{{ end }}
···
69
<div class="flex justify-between items-center">
70
<a href="/{{ $.BaseBlobLink }}/{{ .Name }}" class="{{ $linkstyle }}">
71
<div class="flex items-center gap-2">
72
+
{{ i "file" "size-4" }}{{ .Name }}
73
</div>
74
</a>
75
+
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time>
76
</div>
77
</div>
78
{{ end }}
+33
-33
appview/pages/templates/settings.html
+33
-33
appview/pages/templates/settings.html
···
2
3
{{ define "content" }}
4
<div class="p-6">
5
-
<p class="text-xl font-bold">Settings</p>
6
</div>
7
<div class="flex flex-col">
8
{{ block "profile" . }} {{ end }}
···
12
{{ end }}
13
14
{{ define "profile" }}
15
-
<h2 class="text-sm font-bold py-2 px-6 uppercase">profile</h2>
16
-
<section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
17
-
<dl class="grid grid-cols-[auto_1fr] gap-x-4">
18
{{ if .LoggedInUser.Handle }}
19
<dt class="font-bold">handle</dt>
20
<dd>@{{ .LoggedInUser.Handle }}</dd>
···
28
{{ end }}
29
30
{{ define "keys" }}
31
-
<h2 class="text-sm font-bold py-2 px-6 uppercase">ssh keys</h2>
32
-
<section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
33
-
<p class="mb-8">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p>
34
<div id="key-list" class="flex flex-col gap-6 mb-8">
35
{{ range $index, $key := .PubKeys }}
36
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
37
<div class="flex flex-col gap-1">
38
<div class="inline-flex items-center gap-4">
39
-
{{ i "key" "w-3 h-3" }}
40
-
<p class="font-bold">{{ .Name }}</p>
41
</div>
42
-
<p class="text-sm text-gray-500">added {{ .Created | timeFmt }}</p>
43
<div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full">
44
-
<code class="text-sm text-gray-500">{{ .Key }}</code>
45
</div>
46
</div>
47
<button
48
-
class="btn text-red-500 hover:text-red-700"
49
title="Delete key"
50
hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}"
51
hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?">
···
66
name="name"
67
placeholder="key name"
68
required
69
-
class="w-full"/>
70
71
<input
72
id="key"
73
name="key"
74
placeholder="ssh-rsa AAAAAA..."
75
required
76
-
class="w-full"/>
77
78
-
<button class="btn" type="submit">add key</button>
79
80
-
<div id="settings-keys" class="error"></div>
81
</form>
82
</section>
83
{{ end }}
84
85
{{ define "emails" }}
86
-
<h2 class="text-sm font-bold py-2 px-6 uppercase">email addresses</h2>
87
-
<section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
88
-
<p class="mb-8">Commits authored using emails listed here will be associated with your Tangled profile.</p>
89
<div id="email-list" class="flex flex-col gap-6 mb-8">
90
{{ range $index, $email := .Emails }}
91
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
92
<div class="flex flex-col gap-2">
93
<div class="inline-flex items-center gap-4">
94
-
{{ i "mail" "w-3 h-3" }}
95
-
<p class="font-bold">{{ .Address }}</p>
96
<div class="inline-flex items-center gap-1">
97
{{ if .Verified }}
98
-
<span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">verified</span>
99
{{ else }}
100
-
<span class="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">unverified</span>
101
{{ end }}
102
{{ if .Primary }}
103
-
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">primary</span>
104
{{ end }}
105
</div>
106
</div>
107
-
<p class="text-sm text-gray-500">added {{ .CreatedAt | timeFmt }}</p>
108
</div>
109
<div class="flex gap-2 items-center">
110
{{ if not .Verified }}
111
<button
112
-
class="btn flex gap-2"
113
hx-post="/settings/emails/verify/resend"
114
hx-swap="none"
115
href="#"
···
120
{{ end }}
121
{{ if and (not .Primary) .Verified }}
122
<a
123
-
class="text-sm"
124
hx-post="/settings/emails/primary"
125
hx-swap="none"
126
href="#"
···
132
<form hx-delete="/settings/emails" hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?">
133
<input type="hidden" name="email" value="{{ .Address }}">
134
<button
135
-
class="btn text-red-500 hover:text-red-700"
136
title="Delete email"
137
type="submit">
138
{{ i "trash-2" "w-5 h-5" }}
···
155
name="email"
156
placeholder="your@email.com"
157
required
158
-
class="w-full"/>
159
160
-
<button class="btn" type="submit">add email</button>
161
162
-
<div id="settings-emails-error" class="error"></div>
163
-
<div id="settings-emails-success" class="success"></div>
164
165
</form>
166
</section>
167
-
{{ end }}
···
2
3
{{ define "content" }}
4
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
</div>
7
<div class="flex flex-col">
8
{{ block "profile" . }} {{ end }}
···
12
{{ end }}
13
14
{{ define "profile" }}
15
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">profile</h2>
16
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
17
+
<dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200">
18
{{ if .LoggedInUser.Handle }}
19
<dt class="font-bold">handle</dt>
20
<dd>@{{ .LoggedInUser.Handle }}</dd>
···
28
{{ end }}
29
30
{{ define "keys" }}
31
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">ssh keys</h2>
32
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
33
+
<p class="mb-8 dark:text-gray-300">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p>
34
<div id="key-list" class="flex flex-col gap-6 mb-8">
35
{{ range $index, $key := .PubKeys }}
36
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
37
<div class="flex flex-col gap-1">
38
<div class="inline-flex items-center gap-4">
39
+
{{ i "key" "w-3 h-3 dark:text-gray-300" }}
40
+
<p class="font-bold dark:text-white">{{ .Name }}</p>
41
</div>
42
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ .Created | timeFmt }}</p>
43
<div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full">
44
+
<code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code>
45
</div>
46
</div>
47
<button
48
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2"
49
title="Delete key"
50
hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}"
51
hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?">
···
66
name="name"
67
placeholder="key name"
68
required
69
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
70
71
<input
72
id="key"
73
name="key"
74
placeholder="ssh-rsa AAAAAA..."
75
required
76
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
77
78
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add key</button>
79
80
+
<div id="settings-keys" class="error dark:text-red-400"></div>
81
</form>
82
</section>
83
{{ end }}
84
85
{{ define "emails" }}
86
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2>
87
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
88
+
<p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p>
89
<div id="email-list" class="flex flex-col gap-6 mb-8">
90
{{ range $index, $email := .Emails }}
91
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
92
<div class="flex flex-col gap-2">
93
<div class="inline-flex items-center gap-4">
94
+
{{ i "mail" "w-3 h-3 dark:text-gray-300" }}
95
+
<p class="font-bold dark:text-white">{{ .Address }}</p>
96
<div class="inline-flex items-center gap-1">
97
{{ if .Verified }}
98
+
<span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span>
99
{{ else }}
100
+
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span>
101
{{ end }}
102
{{ if .Primary }}
103
+
<span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span>
104
{{ end }}
105
</div>
106
</div>
107
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ .CreatedAt | timeFmt }}</p>
108
</div>
109
<div class="flex gap-2 items-center">
110
{{ if not .Verified }}
111
<button
112
+
class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
113
hx-post="/settings/emails/verify/resend"
114
hx-swap="none"
115
href="#"
···
120
{{ end }}
121
{{ if and (not .Primary) .Verified }}
122
<a
123
+
class="text-sm dark:text-blue-400 dark:hover:text-blue-300"
124
hx-post="/settings/emails/primary"
125
hx-swap="none"
126
href="#"
···
132
<form hx-delete="/settings/emails" hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?">
133
<input type="hidden" name="email" value="{{ .Address }}">
134
<button
135
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
136
title="Delete email"
137
type="submit">
138
{{ i "trash-2" "w-5 h-5" }}
···
155
name="email"
156
placeholder="your@email.com"
157
required
158
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
159
160
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add email</button>
161
162
+
<div id="settings-emails-error" class="error dark:text-red-400"></div>
163
+
<div id="settings-emails-success" class="success dark:text-green-400"></div>
164
165
</form>
166
</section>
167
+
{{ end }}
+22
-14
appview/pages/templates/timeline.html
+22
-14
appview/pages/templates/timeline.html
···
17
{{ end }}
18
19
{{ define "hero" }}
20
-
<div class="flex flex-col items-center justify-center text-center rounded drop-shadow bg-white text-black py-4 px-10">
21
<div class="font-bold italic text-4xl mb-4">
22
tangled
23
</div>
24
<div class="italic text-lg">
25
tightly-knit social coding, <a href="/login" class="underline inline-flex gap-1 items-center">join now {{ i "arrow-right" "w-4 h-4" }}</a>
26
-
<p class="pt-5 px-10 text-sm text-gray-500">Join our IRC channel: <a href="https://web.libera.chat/#tangled"><code>#tangled</code> on Libera Chat</a>.
27
Read an introduction to Tangled <a href="https://blog.tangled.sh/intro">here</a>.</p>
28
</div>
29
</div>
···
32
{{ define "timeline" }}
33
<div>
34
<div class="p-6">
35
-
<p class="text-xl font-bold">Timeline</p>
36
</div>
37
38
<div class="flex flex-col gap-3 relative">
39
-
<div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300"></div>
40
{{ range .Timeline }}
41
-
<div class="px-6 py-2 bg-white rounded drop-shadow-sm w-fit">
42
{{ if .Repo }}
43
{{ $userHandle := index $.DidHandleMap .Repo.Did }}
44
<div class="flex items-center">
45
-
<p class="text-gray-600">
46
<a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a>
47
-
created
48
-
<a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
49
-
<time class="text-gray-700 text-xs">{{ .Repo.Created | timeFmt }}</time>
50
</p>
51
</div>
52
{{ else if .Follow }}
53
{{ $userHandle := index $.DidHandleMap .Follow.UserDid }}
54
{{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }}
55
<div class="flex items-center">
56
-
<p class="text-gray-600">
57
<a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a>
58
followed
59
<a href="/{{ $subjectHandle }}" class="no-underline hover:underline">{{ $subjectHandle | truncateAt30 }}</a>
60
-
<time class="text-gray-700 text-xs">{{ .Follow.FollowedAt | timeFmt }}</time>
61
</p>
62
</div>
63
{{ else if .Star }}
64
{{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }}
65
{{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }}
66
<div class="flex items-center">
67
-
<p class="text-gray-600">
68
<a href="/{{ $starrerHandle }}" class="no-underline hover:underline">{{ $starrerHandle | truncateAt30 }}</a>
69
starred
70
<a href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}" class="no-underline hover:underline">{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a>
71
-
<time class="text-gray-700 text-xs">{{ .Star.Created | timeFmt }}</time>
72
</p>
73
</div>
74
{{ end }}
···
77
</div>
78
</div>
79
{{ end }}
80
-
···
17
{{ end }}
18
19
{{ define "hero" }}
20
+
<div class="flex flex-col items-center justify-center text-center rounded drop-shadow bg-white dark:bg-gray-800 text-black dark:text-white py-4 px-10">
21
<div class="font-bold italic text-4xl mb-4">
22
tangled
23
</div>
24
<div class="italic text-lg">
25
tightly-knit social coding, <a href="/login" class="underline inline-flex gap-1 items-center">join now {{ i "arrow-right" "w-4 h-4" }}</a>
26
+
<p class="pt-5 px-10 text-sm text-gray-500 dark:text-gray-400">Join our <a href="https://chat.tangled.sh">Discord</a> or IRC channel: <a href="https://web.libera.chat/#tangled"><code>#tangled</code> on Libera Chat</a>.
27
Read an introduction to Tangled <a href="https://blog.tangled.sh/intro">here</a>.</p>
28
</div>
29
</div>
···
32
{{ define "timeline" }}
33
<div>
34
<div class="p-6">
35
+
<p class="text-xl font-bold dark:text-white">Timeline</p>
36
</div>
37
38
<div class="flex flex-col gap-3 relative">
39
+
<div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600"></div>
40
{{ range .Timeline }}
41
+
<div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit">
42
{{ if .Repo }}
43
{{ $userHandle := index $.DidHandleMap .Repo.Did }}
44
<div class="flex items-center">
45
+
<p class="text-gray-600 dark:text-gray-300">
46
<a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a>
47
+
{{ if .Source }}
48
+
forked
49
+
<a href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}" class="no-underline hover:underline">
50
+
{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}
51
+
</a>
52
+
to
53
+
<a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
54
+
{{ else }}
55
+
created
56
+
<a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
57
+
{{ end }}
58
+
<time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Repo.Created | timeFmt }}</time>
59
</p>
60
</div>
61
{{ else if .Follow }}
62
{{ $userHandle := index $.DidHandleMap .Follow.UserDid }}
63
{{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }}
64
<div class="flex items-center">
65
+
<p class="text-gray-600 dark:text-gray-300">
66
<a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a>
67
followed
68
<a href="/{{ $subjectHandle }}" class="no-underline hover:underline">{{ $subjectHandle | truncateAt30 }}</a>
69
+
<time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Follow.FollowedAt | timeFmt }}</time>
70
</p>
71
</div>
72
{{ else if .Star }}
73
{{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }}
74
{{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }}
75
<div class="flex items-center">
76
+
<p class="text-gray-600 dark:text-gray-300">
77
<a href="/{{ $starrerHandle }}" class="no-underline hover:underline">{{ $starrerHandle | truncateAt30 }}</a>
78
starred
79
<a href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}" class="no-underline hover:underline">{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a>
80
+
<time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Star.Created | timeFmt }}</time>
81
</p>
82
</div>
83
{{ end }}
···
86
</div>
87
</div>
88
{{ end }}
+6
appview/pages/templates/user/fragments/bluesky.html
+6
appview/pages/templates/user/fragments/bluesky.html
···
···
1
+
{{ define "user/fragments/bluesky" }}
2
+
<svg class="{{.}}" xmlns="http://www.w3.org/2000/svg" role="img" viewBox="-3 -3 30 30">
3
+
<title>Bluesky</title>
4
+
<path fill="none" stroke="currentColor" d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" stroke-width="2.25"/>
5
+
</svg>
6
+
{{ end }}
+111
appview/pages/templates/user/fragments/editBio.html
+111
appview/pages/templates/user/fragments/editBio.html
···
···
1
+
{{ define "user/fragments/editBio" }}
2
+
<form
3
+
hx-post="/profile/bio"
4
+
class="flex flex-col gap-4 my-2 max-w-full"
5
+
hx-disabled-elt="#save-btn,#cancel-btn"
6
+
hx-swap="none"
7
+
hx-indicator="#spinner">
8
+
<div class="flex flex-col gap-1">
9
+
{{ $description := "" }}
10
+
{{ if and .Profile .Profile.Description }}
11
+
{{ $description = .Profile.Description }}
12
+
{{ end }}
13
+
<label class="m-0 p-0" for="description">bio</label>
14
+
<textarea
15
+
type="text"
16
+
class="py-1 px-1 w-full"
17
+
name="description"
18
+
rows="3"
19
+
placeholder="write a bio">{{ $description }}</textarea>
20
+
</div>
21
+
22
+
<div class="flex flex-col gap-1">
23
+
<label class="m-0 p-0" for="location">location</label>
24
+
<div class="flex items-center gap-2 w-full">
25
+
{{ $location := "" }}
26
+
{{ if and .Profile .Profile.Location }}
27
+
{{ $location = .Profile.Location }}
28
+
{{ end }}
29
+
<span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span>
30
+
<input type="text" class="py-1 px-1 w-full" name="location" value="{{ $location }}">
31
+
</div>
32
+
</div>
33
+
34
+
<div class="flex flex-col gap-1">
35
+
<label class="m-0 p-0">social links</label>
36
+
<div class="flex items-center gap-2 py-1">
37
+
{{ $includeBsky := false }}
38
+
{{ if and .Profile .Profile.IncludeBluesky }}
39
+
{{ $includeBsky = true }}
40
+
{{ end }}
41
+
<input type="checkbox" id="includeBluesky" name="includeBluesky" value="on" {{if $includeBsky}}checked{{end}}>
42
+
<label for="includeBluesky" class="my-0 py-0 normal-case font-normal">Link to Bluesky account</label>
43
+
</div>
44
+
45
+
{{ $profile := .Profile }}
46
+
{{ range $idx, $s := (sequence 5) }}
47
+
{{ $link := "" }}
48
+
{{ if and $profile $profile.Links }}
49
+
{{ if lt $idx (len $profile.Links) }}
50
+
{{ $link = index $profile.Links $idx }}
51
+
{{ end }}
52
+
{{ end }}
53
+
54
+
<div class="flex items-center gap-2 w-full">
55
+
<span class="flex-shrink-0">{{ i "link" "size-4" }}</span>
56
+
<input type="text" class="py-1 px-1 w-full" name="link{{$idx}}" value="{{ $link }}" placeholder="social link {{add $idx 1}}">
57
+
</div>
58
+
{{ end }}
59
+
</div>
60
+
61
+
<div class="flex flex-col gap-1">
62
+
<label class="m-0 p-0">vanity stats</label>
63
+
{{ range $idx, $s := (sequence 2) }}
64
+
{{ $stat := "" }}
65
+
{{ if and $profile $profile.Stats }}
66
+
{{ if lt $idx (len $profile.Stats) }}
67
+
{{ $s := index $profile.Stats $idx }}
68
+
{{ $stat = $s.Kind }}
69
+
{{ end }}
70
+
{{ end }}
71
+
72
+
{{ block "stat" (list $idx $stat) }} {{ end }}
73
+
{{ end }}
74
+
</div>
75
+
76
+
<div class="flex items-center gap-2 justify-between">
77
+
<button id="save-btn" type="submit" class="btn p-1 w-full flex items-center gap-2 no-underline text-sm">
78
+
{{ i "check" "size-4" }} save
79
+
<span id="spinner" class="group">
80
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
81
+
</span>
82
+
</button>
83
+
<a href="/{{.LoggedInUser.Did}}" class="w-full no-underline hover:no-underline">
84
+
<button id="cancel-btn" type="button" class="btn p-1 w-full flex items-center gap-2 no-underline text-sm">
85
+
{{ i "x" "size-4" }} cancel
86
+
</button>
87
+
</a>
88
+
</div>
89
+
</form>
90
+
{{ end }}
91
+
92
+
{{ define "stat" }}
93
+
{{ $id := index . 0 }}
94
+
{{ $stat := index . 1 }}
95
+
<select class="stat-group w-full p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700 text-sm" id="stat{{$id}}" name="stat{{$id}}">
96
+
<option value="">choose stat</option>
97
+
{{ $stats := assoc
98
+
"merged-pull-request-count" "Merged PR Count"
99
+
"closed-pull-request-count" "Closed PR Count"
100
+
"open-pull-request-count" "Open PR Count"
101
+
"open-issue-count" "Open Issue Count"
102
+
"closed-issue-count" "Closed Issue Count"
103
+
"repository-count" "Repository Count"
104
+
}}
105
+
{{ range $s := $stats }}
106
+
{{ $value := index $s 0 }}
107
+
{{ $label := index $s 1 }}
108
+
<option value="{{ $value }}"{{ if eq $stat $value }} selected{{ end }}>{{ $label }}</option>
109
+
{{ end }}
110
+
</select>
111
+
{{ end }}
+42
appview/pages/templates/user/fragments/editPins.html
+42
appview/pages/templates/user/fragments/editPins.html
···
···
1
+
{{ define "user/fragments/editPins" }}
2
+
{{ $profile := .Profile }}
3
+
<form
4
+
hx-post="/profile/pins"
5
+
hx-disabled-elt="#save-btn,#cancel-btn"
6
+
hx-swap="none"
7
+
hx-indicator="#spinner">
8
+
<div class="flex items-center justify-between mb-2">
9
+
<p class="text-sm font-bold p-2 dark:text-white">SELECT PINNED REPOS</p>
10
+
<div class="flex items-center gap-2">
11
+
<button id="save-btn" type="submit" class="btn px-2 flex items-center gap-2 no-underline text-sm">
12
+
{{ i "check" "w-3 h-3" }} save
13
+
<span id="spinner" class="group">
14
+
{{ i "loader-circle" "w-3 h-3 animate-spin hidden group-[.htmx-request]:inline" }}
15
+
</span>
16
+
</button>
17
+
<a href="/{{.LoggedInUser.Did}}" class="w-full no-underline hover:no-underline">
18
+
<button id="cancel-btn" type="button" class="btn px-2 w-full flex items-center gap-2 no-underline text-sm">
19
+
{{ i "x" "w-3 h-3" }} cancel
20
+
</button>
21
+
</a>
22
+
</div>
23
+
</div>
24
+
<div id="repos" class="grid grid-cols-1 gap-1 mb-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
25
+
{{ range $idx, $r := .AllRepos }}
26
+
<div class="flex items-center gap-2 text-base p-2 border-b border-gray-200 dark:border-gray-700">
27
+
<input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}>
28
+
<label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full">
29
+
<div class="flex justify-between items-center w-full">
30
+
<span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ index $.DidHandleMap .Did }}/{{.Name}}</span>
31
+
<div class="flex gap-1 items-center">
32
+
{{ i "star" "size-4 fill-current" }}
33
+
<span>{{ .RepoStats.StarCount }}</span>
34
+
</div>
35
+
</div>
36
+
</label>
37
+
</div>
38
+
{{ end }}
39
+
</div>
40
+
41
+
</form>
42
+
{{ end }}
+17
appview/pages/templates/user/fragments/follow.html
+17
appview/pages/templates/user/fragments/follow.html
···
···
1
+
{{ define "user/fragments/follow" }}
2
+
<button id="followBtn"
3
+
class="btn mt-2 w-full"
4
+
5
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
+
hx-post="/follow?subject={{.UserDid}}"
7
+
{{ else }}
8
+
hx-delete="/follow?subject={{.UserDid}}"
9
+
{{ end }}
10
+
11
+
hx-trigger="click"
12
+
hx-target="#followBtn"
13
+
hx-swap="outerHTML"
14
+
>
15
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
16
+
</button>
17
+
{{ end }}
+97
appview/pages/templates/user/fragments/profileCard.html
+97
appview/pages/templates/user/fragments/profileCard.html
···
···
1
+
{{ define "user/fragments/profileCard" }}
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
+
<img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" />
7
+
{{ end }}
8
+
</div>
9
+
<div class="col-span-2">
10
+
<p title="{{ didOrHandle .UserDid .UserHandle }}"
11
+
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
12
+
{{ didOrHandle .UserDid .UserHandle }}
13
+
</p>
14
+
15
+
<div class="md:hidden">
16
+
{{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
17
+
</div>
18
+
</div>
19
+
<div class="col-span-3 md:col-span-full">
20
+
<div id="profile-bio" class="text-sm">
21
+
{{ $profile := .Profile }}
22
+
{{ with .Profile }}
23
+
24
+
{{ if .Description }}
25
+
<p class="text-base pb-4 md:pb-2">{{ .Description }}</p>
26
+
{{ end }}
27
+
28
+
<div class="hidden md:block">
29
+
{{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }}
30
+
</div>
31
+
32
+
<div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
33
+
{{ if .Location }}
34
+
<div class="flex items-center gap-2">
35
+
<span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span>
36
+
<span>{{ .Location }}</span>
37
+
</div>
38
+
{{ end }}
39
+
{{ if .IncludeBluesky }}
40
+
<div class="flex items-center gap-2">
41
+
<span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span>
42
+
<a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a>
43
+
</div>
44
+
{{ end }}
45
+
{{ range $link := .Links }}
46
+
{{ if $link }}
47
+
<div class="flex items-center gap-2">
48
+
<span class="flex-shrink-0">{{ i "link" "size-4" }}</span>
49
+
<a href="{{ $link }}">{{ $link }}</a>
50
+
</div>
51
+
{{ end }}
52
+
{{ end }}
53
+
{{ if not $profile.IsStatsEmpty }}
54
+
<div class="flex items-center justify-evenly gap-2 py-2">
55
+
{{ range $stat := .Stats }}
56
+
{{ if $stat.Kind }}
57
+
<div class="flex flex-col items-center gap-2">
58
+
<span class="text-xl font-bold">{{ $stat.Value }}</span>
59
+
<span>{{ $stat.Kind.String }}</span>
60
+
</div>
61
+
{{ end }}
62
+
{{ end }}
63
+
</div>
64
+
{{ end }}
65
+
</div>
66
+
{{ end }}
67
+
{{ if ne .FollowStatus.String "IsSelf" }}
68
+
{{ template "user/fragments/follow" . }}
69
+
{{ else }}
70
+
<button id="editBtn"
71
+
class="btn mt-2 w-full flex items-center gap-2 group"
72
+
hx-target="#profile-bio"
73
+
hx-get="/profile/edit-bio"
74
+
hx-swap="innerHTML">
75
+
{{ i "pencil" "w-4 h-4" }}
76
+
edit
77
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
78
+
</button>
79
+
{{ end }}
80
+
</div>
81
+
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
82
+
</div>
83
+
</div>
84
+
</div>
85
+
{{ end }}
86
+
87
+
{{ define "followerFollowing" }}
88
+
{{ $followers := index . 0 }}
89
+
{{ $following := index . 1 }}
90
+
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
91
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
92
+
<span id="followers">{{ $followers }} followers</span>
93
+
<span class="select-none after:content-['ยท']"></span>
94
+
<span id="following">{{ $following }} following</span>
95
+
</div>
96
+
{{ end }}
97
+
+25
-28
appview/pages/templates/user/login.html
+25
-28
appview/pages/templates/user/login.html
···
1
{{ define "user/login" }}
2
<!doctype html>
3
-
<html lang="en">
4
<head>
5
<meta charset="UTF-8" />
6
<meta
···
8
content="width=device-width, initial-scale=1.0"
9
/>
10
<script src="/static/htmx.min.js"></script>
11
-
<link rel="stylesheet" href="/static/tw.css" type="text/css" />
12
<title>login</title>
13
</head>
14
<body class="flex items-center justify-center min-h-screen">
15
-
<main class="max-w-64">
16
-
<h1 class="text-center text-2xl font-semibold italic">
17
tangled
18
</h1>
19
-
<h2 class="text-center text-xl italic">
20
tightly-knit social coding.
21
</h2>
22
<form
23
-
class="w-full mt-4"
24
hx-post="/login"
25
hx-swap="none"
26
-
hx-disabled-elt="this"
27
>
28
<div class="flex flex-col">
29
<label for="handle">handle</label>
30
-
<input type="text" id="handle" name="handle" required />
31
-
<span class="text-xs text-gray-500 mt-1">
32
-
You need to use your
33
-
<a href="https://bsky.app">Bluesky</a> handle to log
34
-
in.
35
-
</span>
36
-
</div>
37
-
38
-
<div class="flex flex-col mt-2">
39
-
<label for="app_password">app password</label>
40
<input
41
-
type="password"
42
-
id="app_password"
43
-
name="app_password"
44
required
45
/>
46
-
<span class="text-xs text-gray-500 mt-1">
47
-
Generate an app password
48
-
<a
49
-
href="https://bsky.app/settings/app-passwords"
50
-
target="_blank"
51
-
>here</a
52
-
>.
53
</span>
54
</div>
55
···
57
class="btn w-full my-2 mt-6"
58
type="submit"
59
id="login-button"
60
>
61
<span>login</span>
62
</button>
63
</form>
64
<p class="text-sm text-gray-500">
65
-
Join our IRC channel:
66
<a href="https://web.libera.chat/#tangled"
67
><code>#tangled</code> on Libera Chat</a
68
>.
···
1
{{ define "user/login" }}
2
<!doctype html>
3
+
<html lang="en" class="dark:bg-gray-900">
4
<head>
5
<meta charset="UTF-8" />
6
<meta
···
8
content="width=device-width, initial-scale=1.0"
9
/>
10
<script src="/static/htmx.min.js"></script>
11
+
<link
12
+
rel="stylesheet"
13
+
href="/static/tw.css?{{ cssContentHash }}"
14
+
type="text/css"
15
+
/>
16
<title>login</title>
17
</head>
18
<body class="flex items-center justify-center min-h-screen">
19
+
<main class="max-w-md px-6 -mt-4">
20
+
<h1
21
+
class="text-center text-2xl font-semibold italic dark:text-white"
22
+
>
23
tangled
24
</h1>
25
+
<h2 class="text-center text-xl italic dark:text-white">
26
tightly-knit social coding.
27
</h2>
28
<form
29
+
class="mt-4 max-w-sm mx-auto"
30
hx-post="/login"
31
hx-swap="none"
32
+
hx-disabled-elt="#login-button"
33
>
34
<div class="flex flex-col">
35
<label for="handle">handle</label>
36
<input
37
+
type="text"
38
+
id="handle"
39
+
name="handle"
40
+
tabindex="1"
41
required
42
/>
43
+
<span class="text-sm text-gray-500 mt-1">
44
+
Use your
45
+
<a href="https://bsky.app">Bluesky</a> handle to log
46
+
in. You will then be redirected to your PDS to
47
+
complete authentication.
48
</span>
49
</div>
50
···
52
class="btn w-full my-2 mt-6"
53
type="submit"
54
id="login-button"
55
+
tabindex="3"
56
>
57
<span>login</span>
58
</button>
59
</form>
60
<p class="text-sm text-gray-500">
61
+
Join our <a href="https://chat.tangled.sh">Discord</a> or
62
+
IRC channel:
63
<a href="https://web.libera.chat/#tangled"
64
><code>#tangled</code> on Libera Chat</a
65
>.
+287
-84
appview/pages/templates/user/profile.html
+287
-84
appview/pages/templates/user/profile.html
···
1
-
{{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }}
2
3
{{ define "content" }}
4
-
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
5
-
<div class="md:col-span-1">
6
-
{{ block "profileCard" . }}{{ end }}
7
</div>
8
9
-
<div class="md:col-span-3">
10
-
{{ block "ownRepos" . }}{{ end }}
11
-
{{ block "collaboratingRepos" . }}{{ end }}
12
</div>
13
-
</div>
14
{{ end }}
15
16
-
{{ define "profileCard" }}
17
-
<div class="bg-white px-6 py-4 rounded drop-shadow-sm max-h-fit">
18
-
<div class="flex justify-center items-center">
19
-
{{ if .AvatarUri }}
20
-
<img class="w-1/2 rounded-full p-2" src="{{ .AvatarUri }}" />
21
{{ end }}
22
-
</div>
23
-
<p class="text-xl font-bold text-center">
24
-
{{ truncateAt30 (didOrHandle .UserDid .UserHandle) }}
25
-
</p>
26
-
<div class="text-sm text-center">
27
-
<span>{{ .ProfileStats.Followers }} followers</span>
28
-
<div
29
-
class="inline-block px-1 select-none after:content-['ยท']"
30
-
></div>
31
-
<span>{{ .ProfileStats.Following }} following</span>
32
</div>
33
34
-
{{ if ne .FollowStatus.String "IsSelf" }}
35
-
{{ template "fragments/follow" . }}
36
{{ end }}
37
-
</div>
38
{{ end }}
39
40
{{ define "ownRepos" }}
41
-
<p class="text-sm font-bold py-2 px-6">REPOS</p>
42
-
<div id="repos" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
43
-
{{ range .Repos }}
44
-
<div
45
-
id="repo-card"
46
-
class="py-4 px-6 drop-shadow-sm rounded bg-white"
47
-
>
48
-
<div id="repo-card-name" class="font-medium">
49
-
<a href="/@{{ or $.UserHandle $.UserDid }}/{{ .Name }}"
50
-
>{{ .Name }}</a
51
-
>
52
</div>
53
-
{{ if .Description }}
54
-
<div class="text-gray-600 text-sm">
55
-
{{ .Description }}
56
-
</div>
57
-
{{ end }}
58
-
<div
59
-
class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"
60
-
>
61
-
62
-
{{ if .RepoStats.StarCount }}
63
-
<div class="flex gap-1 items-center text-sm">
64
-
{{ i "star" "w-3 h-3 fill-current" }}
65
-
<span>{{ .RepoStats.StarCount }}</span>
66
-
</div>
67
-
{{ end }}
68
-
</div>
69
-
</div>
70
-
{{ else }}
71
-
<p class="px-6">This user does not have any repos yet.</p>
72
-
{{ end }}
73
-
</div>
74
{{ end }}
75
76
{{ define "collaboratingRepos" }}
77
-
<p class="text-sm font-bold py-2 px-6">COLLABORATING ON</p>
78
-
<div id="collaborating" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
79
-
{{ range .CollaboratingRepos }}
80
-
<div
81
-
id="repo-card"
82
-
class="py-4 px-6 drop-shadow-sm rounded bg-white flex flex-col"
83
-
>
84
-
<div id="repo-card-name" class="font-medium">
85
-
<a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}">
86
-
{{ index $.DidHandleMap .Did }}/{{ .Name }}
87
-
</a>
88
</div>
89
-
{{ if .Description }}
90
-
<div class="text-gray-600 text-sm">
91
-
{{ .Description }}
92
</div>
93
{{ end }}
94
-
<div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto">
95
-
96
-
{{ if .RepoStats.StarCount }}
97
-
<div class="flex gap-1 items-center text-sm">
98
-
{{ i "star" "w-3 h-3 fill-current" }}
99
-
<span>{{ .RepoStats.StarCount }}</span>
100
-
</div>
101
-
{{ end }}
102
-
</div>
103
</div>
104
-
{{ else }}
105
-
<p class="px-6">This user is not collaborating.</p>
106
-
{{ end }}
107
</div>
108
{{ end }}
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
3
{{ define "content" }}
4
+
<div class="grid grid-cols-1 md:grid-cols-8 gap-6">
5
+
<div class="md:col-span-2 order-1 md:order-1">
6
+
{{ template "user/fragments/profileCard" .Card }}
7
+
</div>
8
+
<div id="all-repos" class="md:col-span-3 order-2 md:order-2">
9
+
{{ block "ownRepos" . }}{{ end }}
10
+
{{ block "collaboratingRepos" . }}{{ end }}
11
+
</div>
12
+
<div class="md:col-span-3 order-3 md:order-3">
13
+
{{ block "profileTimeline" . }}{{ end }}
14
+
</div>
15
+
</div>
16
+
{{ end }}
17
+
18
+
{{ define "profileTimeline" }}
19
+
<p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p>
20
+
<div class="flex flex-col gap-6 relative">
21
+
{{ with .ProfileTimeline }}
22
+
{{ range $idx, $byMonth := .ByMonth }}
23
+
{{ with $byMonth }}
24
+
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm">
25
+
{{ if eq $idx 0 }}
26
+
27
+
{{ else }}
28
+
{{ $s := "s" }}
29
+
{{ if eq $idx 1 }}
30
+
{{ $s = "" }}
31
+
{{ end }}
32
+
<p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p>
33
+
{{ end }}
34
+
35
+
{{ if .IsEmpty }}
36
+
<div class="text-gray-500 dark:text-gray-400">
37
+
No activity for this month
38
+
</div>
39
+
{{ else }}
40
+
<div class="flex flex-col gap-1">
41
+
{{ block "repoEvents" (list .RepoEvents $.DidHandleMap) }} {{ end }}
42
+
{{ block "issueEvents" (list .IssueEvents $.DidHandleMap) }} {{ end }}
43
+
{{ block "pullEvents" (list .PullEvents $.DidHandleMap) }} {{ end }}
44
+
</div>
45
+
{{ end }}
46
</div>
47
48
+
{{ end }}
49
+
{{ else }}
50
+
<p class="dark:text-white">This user does not have any activity yet.</p>
51
+
{{ end }}
52
+
{{ end }}
53
+
</div>
54
+
{{ end }}
55
+
56
+
{{ define "repoEvents" }}
57
+
{{ $items := index . 0 }}
58
+
{{ $handleMap := index . 1 }}
59
+
60
+
{{ if gt (len $items) 0 }}
61
+
<details>
62
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
63
+
<div class="flex flex-wrap items-center gap-2">
64
+
{{ i "book-plus" "w-4 h-4" }}
65
+
created {{ len $items }} {{if eq (len $items) 1 }}repository{{else}}repositories{{end}}
66
</div>
67
+
</summary>
68
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
69
+
{{ range $items }}
70
+
<div class="flex flex-wrap items-center gap-2">
71
+
<span class="text-gray-500 dark:text-gray-400">
72
+
{{ if .Source }}
73
+
{{ i "git-fork" "w-4 h-4" }}
74
+
{{ else }}
75
+
{{ i "book-plus" "w-4 h-4" }}
76
+
{{ end }}
77
+
</span>
78
+
<a href="/{{ index $handleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
79
+
{{- .Repo.Name -}}
80
+
</a>
81
+
</div>
82
+
{{ end }}
83
+
</div>
84
+
</details>
85
+
{{ end }}
86
{{ end }}
87
88
+
{{ define "issueEvents" }}
89
+
{{ $i := index . 0 }}
90
+
{{ $items := $i.Items }}
91
+
{{ $stats := $i.Stats }}
92
+
{{ $handleMap := index . 1 }}
93
+
94
+
{{ if gt (len $items) 0 }}
95
+
<details>
96
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
97
+
<div class="flex flex-wrap items-center gap-2">
98
+
{{ i "circle-dot" "w-4 h-4" }}
99
+
100
+
<div>
101
+
created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}}
102
+
</div>
103
+
104
+
{{ if gt $stats.Open 0 }}
105
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
106
+
{{$stats.Open}} open
107
+
</span>
108
+
{{ end }}
109
+
110
+
{{ if gt $stats.Closed 0 }}
111
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
112
+
{{$stats.Closed}} closed
113
+
</span>
114
+
{{ end }}
115
+
116
+
</div>
117
+
</summary>
118
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
119
+
{{ range $items }}
120
+
{{ $repoOwner := index $handleMap .Metadata.Repo.Did }}
121
+
{{ $repoName := .Metadata.Repo.Name }}
122
+
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
123
+
124
+
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
125
+
{{ if .Open }}
126
+
<span class="text-green-600 dark:text-green-500">
127
+
{{ i "circle-dot" "w-4 h-4" }}
128
+
</span>
129
+
{{ else }}
130
+
<span class="text-gray-500 dark:text-gray-400">
131
+
{{ i "ban" "w-4 h-4" }}
132
+
</span>
133
{{ end }}
134
+
<div class="flex-none min-w-8 text-right">
135
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
136
+
</div>
137
+
<div class="break-words max-w-full">
138
+
<a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline">
139
+
{{ .Title -}}
140
+
</a>
141
+
on
142
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
143
+
{{$repoUrl}}
144
+
</a>
145
+
</div>
146
+
</div>
147
+
{{ end }}
148
+
</div>
149
+
</details>
150
+
{{ end }}
151
+
{{ end }}
152
+
153
+
{{ define "pullEvents" }}
154
+
{{ $i := index . 0 }}
155
+
{{ $items := $i.Items }}
156
+
{{ $stats := $i.Stats }}
157
+
{{ $handleMap := index . 1 }}
158
+
{{ if gt (len $items) 0 }}
159
+
<details>
160
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
161
+
<div class="flex flex-wrap items-center gap-2">
162
+
{{ i "git-pull-request" "w-4 h-4" }}
163
+
164
+
<div>
165
+
created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}}
166
+
</div>
167
+
168
+
{{ if gt $stats.Open 0 }}
169
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
170
+
{{$stats.Open}} open
171
+
</span>
172
+
{{ end }}
173
+
174
+
{{ if gt $stats.Merged 0 }}
175
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700">
176
+
{{$stats.Merged}} merged
177
+
</span>
178
+
{{ end }}
179
+
180
+
181
+
{{ if gt $stats.Closed 0 }}
182
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
183
+
{{$stats.Closed}} closed
184
+
</span>
185
+
{{ end }}
186
+
187
</div>
188
+
</summary>
189
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
190
+
{{ range $items }}
191
+
{{ $repoOwner := index $handleMap .Repo.Did }}
192
+
{{ $repoName := .Repo.Name }}
193
+
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
194
195
+
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
196
+
{{ if .State.IsOpen }}
197
+
<span class="text-green-600 dark:text-green-500">
198
+
{{ i "git-pull-request" "w-4 h-4" }}
199
+
</span>
200
+
{{ else if .State.IsMerged }}
201
+
<span class="text-purple-600 dark:text-purple-500">
202
+
{{ i "git-merge" "w-4 h-4" }}
203
+
</span>
204
+
{{ else }}
205
+
<span class="text-gray-600 dark:text-gray-300">
206
+
{{ i "git-pull-request-closed" "w-4 h-4" }}
207
+
</span>
208
+
{{ end }}
209
+
<div class="flex-none min-w-8 text-right">
210
+
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
211
+
</div>
212
+
<div class="break-words max-w-full">
213
+
<a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline">
214
+
{{ .Title -}}
215
+
</a>
216
+
on
217
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
218
+
{{$repoUrl}}
219
+
</a>
220
+
</div>
221
+
</div>
222
{{ end }}
223
+
</div>
224
+
</details>
225
+
{{ end }}
226
{{ end }}
227
228
{{ define "ownRepos" }}
229
+
<div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2">
230
+
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos"
231
+
class="flex text-black dark:text-white items-center gap-4 no-underline hover:no-underline group">
232
+
<span>PINNED REPOS</span>
233
+
<span class="flex md:hidden group-hover:flex gap-2 items-center font-normal text-sm text-gray-500 dark:text-gray-400 ">
234
+
view all {{ i "chevron-right" "w-4 h-4" }}
235
+
</span>
236
+
</a>
237
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }}
238
+
<button
239
+
hx-get="profile/edit-pins"
240
+
hx-target="#all-repos"
241
+
class="btn font-normal text-sm flex gap-2 items-center group">
242
+
{{ i "pencil" "w-3 h-3" }}
243
+
edit
244
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
245
+
</button>
246
+
{{ end }}
247
+
</div>
248
+
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
249
+
{{ range .Repos }}
250
+
<div
251
+
id="repo-card"
252
+
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
253
+
<div id="repo-card-name" class="font-medium">
254
+
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}"
255
+
>{{ .Name }}</a
256
+
>
257
+
</div>
258
+
{{ if .Description }}
259
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
260
+
{{ .Description }}
261
+
</div>
262
+
{{ end }}
263
+
<div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto">
264
+
{{ if .RepoStats.StarCount }}
265
+
<div class="flex gap-1 items-center text-sm">
266
+
{{ i "star" "w-3 h-3 fill-current" }}
267
+
<span>{{ .RepoStats.StarCount }}</span>
268
</div>
269
+
{{ end }}
270
+
</div>
271
+
</div>
272
+
{{ else }}
273
+
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
274
+
{{ end }}
275
+
</div>
276
{{ end }}
277
278
{{ define "collaboratingRepos" }}
279
+
{{ if gt (len .CollaboratingRepos) 0 }}
280
+
<p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p>
281
+
<div id="collaborating" class="grid grid-cols-1 gap-4 mb-6">
282
+
{{ range .CollaboratingRepos }}
283
+
<div
284
+
id="repo-card"
285
+
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col">
286
+
<div id="repo-card-name" class="font-medium dark:text-white">
287
+
<a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}">
288
+
{{ index $.DidHandleMap .Did }}/{{ .Name }}
289
+
</a>
290
+
</div>
291
+
{{ if .Description }}
292
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
293
+
{{ .Description }}
294
</div>
295
+
{{ end }}
296
+
<div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto">
297
+
298
+
{{ if .RepoStats.StarCount }}
299
+
<div class="flex gap-1 items-center text-sm">
300
+
{{ i "star" "w-3 h-3 fill-current" }}
301
+
<span>{{ .RepoStats.StarCount }}</span>
302
</div>
303
{{ end }}
304
</div>
305
+
</div>
306
+
{{ else }}
307
+
<p class="px-6 dark:text-white">This user is not collaborating.</p>
308
+
{{ end }}
309
</div>
310
+
{{ end }}
311
{{ end }}
+44
appview/pages/templates/user/repos.html
+44
appview/pages/templates/user/repos.html
···
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="grid grid-cols-1 md:grid-cols-8 gap-6">
5
+
<div class="md:col-span-2 order-1 md:order-1">
6
+
{{ template "user/fragments/profileCard" .Card }}
7
+
</div>
8
+
<div id="all-repos" class="md:col-span-6 order-2 md:order-2">
9
+
{{ block "ownRepos" . }}{{ end }}
10
+
</div>
11
+
</div>
12
+
{{ end }}
13
+
14
+
{{ define "ownRepos" }}
15
+
<p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p>
16
+
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
17
+
{{ range .Repos }}
18
+
<div
19
+
id="repo-card"
20
+
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
21
+
<div id="repo-card-name" class="font-medium">
22
+
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}"
23
+
>{{ .Name }}</a
24
+
>
25
+
</div>
26
+
{{ if .Description }}
27
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
28
+
{{ .Description }}
29
+
</div>
30
+
{{ end }}
31
+
<div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto">
32
+
{{ if .RepoStats.StarCount }}
33
+
<div class="flex gap-1 items-center text-sm">
34
+
{{ i "star" "w-3 h-3 fill-current" }}
35
+
<span>{{ .RepoStats.StarCount }}</span>
36
+
</div>
37
+
{{ end }}
38
+
</div>
39
+
</div>
40
+
{{ else }}
41
+
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
42
+
{{ end }}
43
+
</div>
44
+
{{ end }}
+31
appview/pagination/page.go
+31
appview/pagination/page.go
···
···
1
+
package pagination
2
+
3
+
type Page struct {
4
+
Offset int // where to start from
5
+
Limit int // number of items in a page
6
+
}
7
+
8
+
func FirstPage() Page {
9
+
return Page{
10
+
Offset: 0,
11
+
Limit: 10,
12
+
}
13
+
}
14
+
15
+
func (p Page) Previous() Page {
16
+
if p.Offset-p.Limit < 0 {
17
+
return FirstPage()
18
+
} else {
19
+
return Page{
20
+
Offset: p.Offset - p.Limit,
21
+
Limit: p.Limit,
22
+
}
23
+
}
24
+
}
25
+
26
+
func (p Page) Next() Page {
27
+
return Page{
28
+
Offset: p.Offset + p.Limit,
29
+
Limit: p.Limit,
30
+
}
31
+
}
+460
appview/settings/settings.go
+460
appview/settings/settings.go
···
···
1
+
package settings
2
+
3
+
import (
4
+
"database/sql"
5
+
"errors"
6
+
"fmt"
7
+
"log"
8
+
"net/http"
9
+
"net/url"
10
+
"strings"
11
+
"time"
12
+
13
+
"github.com/go-chi/chi/v5"
14
+
"tangled.sh/tangled.sh/core/api/tangled"
15
+
"tangled.sh/tangled.sh/core/appview"
16
+
"tangled.sh/tangled.sh/core/appview/db"
17
+
"tangled.sh/tangled.sh/core/appview/email"
18
+
"tangled.sh/tangled.sh/core/appview/middleware"
19
+
"tangled.sh/tangled.sh/core/appview/oauth"
20
+
"tangled.sh/tangled.sh/core/appview/pages"
21
+
22
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
23
+
lexutil "github.com/bluesky-social/indigo/lex/util"
24
+
"github.com/gliderlabs/ssh"
25
+
"github.com/google/uuid"
26
+
)
27
+
28
+
type Settings struct {
29
+
Db *db.DB
30
+
OAuth *oauth.OAuth
31
+
Pages *pages.Pages
32
+
Config *appview.Config
33
+
}
34
+
35
+
func (s *Settings) Router() http.Handler {
36
+
r := chi.NewRouter()
37
+
38
+
r.Use(middleware.AuthMiddleware(s.OAuth))
39
+
40
+
r.Get("/", s.settings)
41
+
42
+
r.Route("/keys", func(r chi.Router) {
43
+
r.Put("/", s.keys)
44
+
r.Delete("/", s.keys)
45
+
})
46
+
47
+
r.Route("/emails", func(r chi.Router) {
48
+
r.Put("/", s.emails)
49
+
r.Delete("/", s.emails)
50
+
r.Get("/verify", s.emailsVerify)
51
+
r.Post("/verify/resend", s.emailsVerifyResend)
52
+
r.Post("/primary", s.emailsPrimary)
53
+
})
54
+
55
+
return r
56
+
}
57
+
58
+
func (s *Settings) settings(w http.ResponseWriter, r *http.Request) {
59
+
user := s.OAuth.GetUser(r)
60
+
pubKeys, err := db.GetPublicKeys(s.Db, user.Did)
61
+
if err != nil {
62
+
log.Println(err)
63
+
}
64
+
65
+
emails, err := db.GetAllEmails(s.Db, user.Did)
66
+
if err != nil {
67
+
log.Println(err)
68
+
}
69
+
70
+
s.Pages.Settings(w, pages.SettingsParams{
71
+
LoggedInUser: user,
72
+
PubKeys: pubKeys,
73
+
Emails: emails,
74
+
})
75
+
}
76
+
77
+
// buildVerificationEmail creates an email.Email struct for verification emails
78
+
func (s *Settings) buildVerificationEmail(emailAddr, did, code string) email.Email {
79
+
verifyURL := s.verifyUrl(did, emailAddr, code)
80
+
81
+
return email.Email{
82
+
APIKey: s.Config.Resend.ApiKey,
83
+
From: "noreply@notifs.tangled.sh",
84
+
To: emailAddr,
85
+
Subject: "Verify your Tangled email",
86
+
Text: `Click the link below (or copy and paste it into your browser) to verify your email address.
87
+
` + verifyURL,
88
+
Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>
89
+
<p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`,
90
+
}
91
+
}
92
+
93
+
// sendVerificationEmail handles the common logic for sending verification emails
94
+
func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error {
95
+
emailToSend := s.buildVerificationEmail(emailAddr, did, code)
96
+
97
+
err := email.SendEmail(emailToSend)
98
+
if err != nil {
99
+
log.Printf("sending email: %s", err)
100
+
s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext))
101
+
return err
102
+
}
103
+
104
+
return nil
105
+
}
106
+
107
+
func (s *Settings) emails(w http.ResponseWriter, r *http.Request) {
108
+
switch r.Method {
109
+
case http.MethodGet:
110
+
s.Pages.Notice(w, "settings-emails", "Unimplemented.")
111
+
log.Println("unimplemented")
112
+
return
113
+
case http.MethodPut:
114
+
did := s.OAuth.GetDid(r)
115
+
emAddr := r.FormValue("email")
116
+
emAddr = strings.TrimSpace(emAddr)
117
+
118
+
if !email.IsValidEmail(emAddr) {
119
+
s.Pages.Notice(w, "settings-emails-error", "Invalid email address.")
120
+
return
121
+
}
122
+
123
+
// check if email already exists in database
124
+
existingEmail, err := db.GetEmail(s.Db, did, emAddr)
125
+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
126
+
log.Printf("checking for existing email: %s", err)
127
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
128
+
return
129
+
}
130
+
131
+
if err == nil {
132
+
if existingEmail.Verified {
133
+
s.Pages.Notice(w, "settings-emails-error", "This email is already verified.")
134
+
return
135
+
}
136
+
137
+
s.Pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.")
138
+
return
139
+
}
140
+
141
+
code := uuid.New().String()
142
+
143
+
// Begin transaction
144
+
tx, err := s.Db.Begin()
145
+
if err != nil {
146
+
log.Printf("failed to start transaction: %s", err)
147
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
148
+
return
149
+
}
150
+
defer tx.Rollback()
151
+
152
+
if err := db.AddEmail(tx, db.Email{
153
+
Did: did,
154
+
Address: emAddr,
155
+
Verified: false,
156
+
VerificationCode: code,
157
+
}); err != nil {
158
+
log.Printf("adding email: %s", err)
159
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
160
+
return
161
+
}
162
+
163
+
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
164
+
return
165
+
}
166
+
167
+
// Commit transaction
168
+
if err := tx.Commit(); err != nil {
169
+
log.Printf("failed to commit transaction: %s", err)
170
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
171
+
return
172
+
}
173
+
174
+
s.Pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.")
175
+
return
176
+
case http.MethodDelete:
177
+
did := s.OAuth.GetDid(r)
178
+
emailAddr := r.FormValue("email")
179
+
emailAddr = strings.TrimSpace(emailAddr)
180
+
181
+
// Begin transaction
182
+
tx, err := s.Db.Begin()
183
+
if err != nil {
184
+
log.Printf("failed to start transaction: %s", err)
185
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
186
+
return
187
+
}
188
+
defer tx.Rollback()
189
+
190
+
if err := db.DeleteEmail(tx, did, emailAddr); err != nil {
191
+
log.Printf("deleting email: %s", err)
192
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
193
+
return
194
+
}
195
+
196
+
// Commit transaction
197
+
if err := tx.Commit(); err != nil {
198
+
log.Printf("failed to commit transaction: %s", err)
199
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
200
+
return
201
+
}
202
+
203
+
s.Pages.HxLocation(w, "/settings")
204
+
return
205
+
}
206
+
}
207
+
208
+
func (s *Settings) verifyUrl(did string, email string, code string) string {
209
+
var appUrl string
210
+
if s.Config.Core.Dev {
211
+
appUrl = "http://" + s.Config.Core.ListenAddr
212
+
} else {
213
+
appUrl = "https://tangled.sh"
214
+
}
215
+
216
+
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
217
+
}
218
+
219
+
func (s *Settings) emailsVerify(w http.ResponseWriter, r *http.Request) {
220
+
q := r.URL.Query()
221
+
222
+
// Get the parameters directly from the query
223
+
emailAddr := q.Get("email")
224
+
did := q.Get("did")
225
+
code := q.Get("code")
226
+
227
+
valid, err := db.CheckValidVerificationCode(s.Db, did, emailAddr, code)
228
+
if err != nil {
229
+
log.Printf("checking email verification: %s", err)
230
+
s.Pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.")
231
+
return
232
+
}
233
+
234
+
if !valid {
235
+
s.Pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.")
236
+
return
237
+
}
238
+
239
+
// Mark email as verified in the database
240
+
if err := db.MarkEmailVerified(s.Db, did, emailAddr); err != nil {
241
+
log.Printf("marking email as verified: %s", err)
242
+
s.Pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.")
243
+
return
244
+
}
245
+
246
+
http.Redirect(w, r, "/settings", http.StatusSeeOther)
247
+
}
248
+
249
+
func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) {
250
+
if r.Method != http.MethodPost {
251
+
s.Pages.Notice(w, "settings-emails-error", "Invalid request method.")
252
+
return
253
+
}
254
+
255
+
did := s.OAuth.GetDid(r)
256
+
emAddr := r.FormValue("email")
257
+
emAddr = strings.TrimSpace(emAddr)
258
+
259
+
if !email.IsValidEmail(emAddr) {
260
+
s.Pages.Notice(w, "settings-emails-error", "Invalid email address.")
261
+
return
262
+
}
263
+
264
+
// Check if email exists and is unverified
265
+
existingEmail, err := db.GetEmail(s.Db, did, emAddr)
266
+
if err != nil {
267
+
if errors.Is(err, sql.ErrNoRows) {
268
+
s.Pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.")
269
+
} else {
270
+
log.Printf("checking for existing email: %s", err)
271
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
272
+
}
273
+
return
274
+
}
275
+
276
+
if existingEmail.Verified {
277
+
s.Pages.Notice(w, "settings-emails-error", "This email is already verified.")
278
+
return
279
+
}
280
+
281
+
// Check if last verification email was sent less than 10 minutes ago
282
+
if existingEmail.LastSent != nil {
283
+
timeSinceLastSent := time.Since(*existingEmail.LastSent)
284
+
if timeSinceLastSent < 10*time.Minute {
285
+
waitTime := 10*time.Minute - timeSinceLastSent
286
+
s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1)))
287
+
return
288
+
}
289
+
}
290
+
291
+
// Generate new verification code
292
+
code := uuid.New().String()
293
+
294
+
// Begin transaction
295
+
tx, err := s.Db.Begin()
296
+
if err != nil {
297
+
log.Printf("failed to start transaction: %s", err)
298
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
299
+
return
300
+
}
301
+
defer tx.Rollback()
302
+
303
+
// Update the verification code and last sent time
304
+
if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil {
305
+
log.Printf("updating email verification: %s", err)
306
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
307
+
return
308
+
}
309
+
310
+
// Send verification email
311
+
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
312
+
return
313
+
}
314
+
315
+
// Commit transaction
316
+
if err := tx.Commit(); err != nil {
317
+
log.Printf("failed to commit transaction: %s", err)
318
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
319
+
return
320
+
}
321
+
322
+
s.Pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.")
323
+
}
324
+
325
+
func (s *Settings) emailsPrimary(w http.ResponseWriter, r *http.Request) {
326
+
did := s.OAuth.GetDid(r)
327
+
emailAddr := r.FormValue("email")
328
+
emailAddr = strings.TrimSpace(emailAddr)
329
+
330
+
if emailAddr == "" {
331
+
s.Pages.Notice(w, "settings-emails-error", "Email address cannot be empty.")
332
+
return
333
+
}
334
+
335
+
if err := db.MakeEmailPrimary(s.Db, did, emailAddr); err != nil {
336
+
log.Printf("setting primary email: %s", err)
337
+
s.Pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.")
338
+
return
339
+
}
340
+
341
+
s.Pages.HxLocation(w, "/settings")
342
+
}
343
+
344
+
func (s *Settings) keys(w http.ResponseWriter, r *http.Request) {
345
+
switch r.Method {
346
+
case http.MethodGet:
347
+
s.Pages.Notice(w, "settings-keys", "Unimplemented.")
348
+
log.Println("unimplemented")
349
+
return
350
+
case http.MethodPut:
351
+
did := s.OAuth.GetDid(r)
352
+
key := r.FormValue("key")
353
+
key = strings.TrimSpace(key)
354
+
name := r.FormValue("name")
355
+
client, err := s.OAuth.AuthorizedClient(r)
356
+
if err != nil {
357
+
s.Pages.Notice(w, "settings-keys", "Failed to authorize. Try again later.")
358
+
return
359
+
}
360
+
361
+
_, _, _, _, err = ssh.ParseAuthorizedKey([]byte(key))
362
+
if err != nil {
363
+
log.Printf("parsing public key: %s", err)
364
+
s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.")
365
+
return
366
+
}
367
+
368
+
rkey := appview.TID()
369
+
370
+
tx, err := s.Db.Begin()
371
+
if err != nil {
372
+
log.Printf("failed to start tx; adding public key: %s", err)
373
+
s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
374
+
return
375
+
}
376
+
defer tx.Rollback()
377
+
378
+
if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil {
379
+
log.Printf("adding public key: %s", err)
380
+
s.Pages.Notice(w, "settings-keys", "Failed to add public key.")
381
+
return
382
+
}
383
+
384
+
// store in pds too
385
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
386
+
Collection: tangled.PublicKeyNSID,
387
+
Repo: did,
388
+
Rkey: rkey,
389
+
Record: &lexutil.LexiconTypeDecoder{
390
+
Val: &tangled.PublicKey{
391
+
CreatedAt: time.Now().Format(time.RFC3339),
392
+
Key: key,
393
+
Name: name,
394
+
}},
395
+
})
396
+
// invalid record
397
+
if err != nil {
398
+
log.Printf("failed to create record: %s", err)
399
+
s.Pages.Notice(w, "settings-keys", "Failed to create record.")
400
+
return
401
+
}
402
+
403
+
log.Println("created atproto record: ", resp.Uri)
404
+
405
+
err = tx.Commit()
406
+
if err != nil {
407
+
log.Printf("failed to commit tx; adding public key: %s", err)
408
+
s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
409
+
return
410
+
}
411
+
412
+
s.Pages.HxLocation(w, "/settings")
413
+
return
414
+
415
+
case http.MethodDelete:
416
+
did := s.OAuth.GetDid(r)
417
+
q := r.URL.Query()
418
+
419
+
name := q.Get("name")
420
+
rkey := q.Get("rkey")
421
+
key := q.Get("key")
422
+
423
+
log.Println(name)
424
+
log.Println(rkey)
425
+
log.Println(key)
426
+
427
+
client, err := s.OAuth.AuthorizedClient(r)
428
+
if err != nil {
429
+
log.Printf("failed to authorize client: %s", err)
430
+
s.Pages.Notice(w, "settings-keys", "Failed to authorize client.")
431
+
return
432
+
}
433
+
434
+
if err := db.DeletePublicKey(s.Db, did, name, key); err != nil {
435
+
log.Printf("removing public key: %s", err)
436
+
s.Pages.Notice(w, "settings-keys", "Failed to remove public key.")
437
+
return
438
+
}
439
+
440
+
if rkey != "" {
441
+
// remove from pds too
442
+
_, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
443
+
Collection: tangled.PublicKeyNSID,
444
+
Repo: did,
445
+
Rkey: rkey,
446
+
})
447
+
448
+
// invalid record
449
+
if err != nil {
450
+
log.Printf("failed to delete record from PDS: %s", err)
451
+
s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.")
452
+
return
453
+
}
454
+
}
455
+
log.Println("deleted successfully")
456
+
457
+
s.Pages.HxLocation(w, "/settings")
458
+
return
459
+
}
460
+
}
+296
appview/state/artifact.go
+296
appview/state/artifact.go
···
···
1
+
package state
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"net/http"
7
+
"net/url"
8
+
"time"
9
+
10
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
+
lexutil "github.com/bluesky-social/indigo/lex/util"
12
+
"github.com/dustin/go-humanize"
13
+
"github.com/go-chi/chi/v5"
14
+
"github.com/go-git/go-git/v5/plumbing"
15
+
"github.com/ipfs/go-cid"
16
+
"tangled.sh/tangled.sh/core/api/tangled"
17
+
"tangled.sh/tangled.sh/core/appview"
18
+
"tangled.sh/tangled.sh/core/appview/db"
19
+
"tangled.sh/tangled.sh/core/appview/knotclient"
20
+
"tangled.sh/tangled.sh/core/appview/pages"
21
+
"tangled.sh/tangled.sh/core/types"
22
+
)
23
+
24
+
// TODO: proper statuses here on early exit
25
+
func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) {
26
+
user := s.oauth.GetUser(r)
27
+
tagParam := chi.URLParam(r, "tag")
28
+
f, err := s.fullyResolvedRepo(r)
29
+
if err != nil {
30
+
log.Println("failed to get repo and knot", err)
31
+
s.pages.Notice(w, "upload", "failed to upload artifact, error in repo resolution")
32
+
return
33
+
}
34
+
35
+
tag, err := s.resolveTag(f, tagParam)
36
+
if err != nil {
37
+
log.Println("failed to resolve tag", err)
38
+
s.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution")
39
+
return
40
+
}
41
+
42
+
file, handler, err := r.FormFile("artifact")
43
+
if err != nil {
44
+
log.Println("failed to upload artifact", err)
45
+
s.pages.Notice(w, "upload", "failed to upload artifact")
46
+
return
47
+
}
48
+
defer file.Close()
49
+
50
+
client, err := s.oauth.AuthorizedClient(r)
51
+
if err != nil {
52
+
log.Println("failed to get authorized client", err)
53
+
s.pages.Notice(w, "upload", "failed to get authorized client")
54
+
return
55
+
}
56
+
57
+
uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file)
58
+
if err != nil {
59
+
log.Println("failed to upload blob", err)
60
+
s.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.")
61
+
return
62
+
}
63
+
64
+
log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String())
65
+
66
+
rkey := appview.TID()
67
+
createdAt := time.Now()
68
+
69
+
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
70
+
Collection: tangled.RepoArtifactNSID,
71
+
Repo: user.Did,
72
+
Rkey: rkey,
73
+
Record: &lexutil.LexiconTypeDecoder{
74
+
Val: &tangled.RepoArtifact{
75
+
Artifact: uploadBlobResp.Blob,
76
+
CreatedAt: createdAt.Format(time.RFC3339),
77
+
Name: handler.Filename,
78
+
Repo: f.RepoAt.String(),
79
+
Tag: tag.Tag.Hash[:],
80
+
},
81
+
},
82
+
})
83
+
if err != nil {
84
+
log.Println("failed to create record", err)
85
+
s.pages.Notice(w, "upload", "Failed to create artifact record. Try again later.")
86
+
return
87
+
}
88
+
89
+
log.Println(putRecordResp.Uri)
90
+
91
+
tx, err := s.db.BeginTx(r.Context(), nil)
92
+
if err != nil {
93
+
log.Println("failed to start tx")
94
+
s.pages.Notice(w, "upload", "Failed to create artifact. Try again later.")
95
+
return
96
+
}
97
+
defer tx.Rollback()
98
+
99
+
artifact := db.Artifact{
100
+
Did: user.Did,
101
+
Rkey: rkey,
102
+
RepoAt: f.RepoAt,
103
+
Tag: tag.Tag.Hash,
104
+
CreatedAt: createdAt,
105
+
BlobCid: cid.Cid(uploadBlobResp.Blob.Ref),
106
+
Name: handler.Filename,
107
+
Size: uint64(uploadBlobResp.Blob.Size),
108
+
MimeType: uploadBlobResp.Blob.MimeType,
109
+
}
110
+
111
+
err = db.AddArtifact(tx, artifact)
112
+
if err != nil {
113
+
log.Println("failed to add artifact record to db", err)
114
+
s.pages.Notice(w, "upload", "Failed to create artifact. Try again later.")
115
+
return
116
+
}
117
+
118
+
err = tx.Commit()
119
+
if err != nil {
120
+
log.Println("failed to add artifact record to db")
121
+
s.pages.Notice(w, "upload", "Failed to create artifact. Try again later.")
122
+
return
123
+
}
124
+
125
+
s.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{
126
+
LoggedInUser: user,
127
+
RepoInfo: f.RepoInfo(s, user),
128
+
Artifact: artifact,
129
+
})
130
+
}
131
+
132
+
// TODO: proper statuses here on early exit
133
+
func (s *State) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
134
+
tagParam := chi.URLParam(r, "tag")
135
+
filename := chi.URLParam(r, "file")
136
+
f, err := s.fullyResolvedRepo(r)
137
+
if err != nil {
138
+
log.Println("failed to get repo and knot", err)
139
+
return
140
+
}
141
+
142
+
tag, err := s.resolveTag(f, tagParam)
143
+
if err != nil {
144
+
log.Println("failed to resolve tag", err)
145
+
s.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution")
146
+
return
147
+
}
148
+
149
+
client, err := s.oauth.AuthorizedClient(r)
150
+
if err != nil {
151
+
log.Println("failed to get authorized client", err)
152
+
return
153
+
}
154
+
155
+
artifacts, err := db.GetArtifact(
156
+
s.db,
157
+
db.Filter("repo_at", f.RepoAt),
158
+
db.Filter("tag", tag.Tag.Hash[:]),
159
+
db.Filter("name", filename),
160
+
)
161
+
if err != nil {
162
+
log.Println("failed to get artifacts", err)
163
+
return
164
+
}
165
+
if len(artifacts) != 1 {
166
+
log.Printf("too many or too little artifacts found")
167
+
return
168
+
}
169
+
170
+
artifact := artifacts[0]
171
+
172
+
getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did)
173
+
if err != nil {
174
+
log.Println("failed to get blob from pds", err)
175
+
return
176
+
}
177
+
178
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
179
+
w.Write(getBlobResp)
180
+
}
181
+
182
+
// TODO: proper statuses here on early exit
183
+
func (s *State) DeleteArtifact(w http.ResponseWriter, r *http.Request) {
184
+
user := s.oauth.GetUser(r)
185
+
tagParam := chi.URLParam(r, "tag")
186
+
filename := chi.URLParam(r, "file")
187
+
f, err := s.fullyResolvedRepo(r)
188
+
if err != nil {
189
+
log.Println("failed to get repo and knot", err)
190
+
return
191
+
}
192
+
193
+
client, _ := s.oauth.AuthorizedClient(r)
194
+
195
+
tag := plumbing.NewHash(tagParam)
196
+
197
+
artifacts, err := db.GetArtifact(
198
+
s.db,
199
+
db.Filter("repo_at", f.RepoAt),
200
+
db.Filter("tag", tag[:]),
201
+
db.Filter("name", filename),
202
+
)
203
+
if err != nil {
204
+
log.Println("failed to get artifacts", err)
205
+
s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.")
206
+
return
207
+
}
208
+
if len(artifacts) != 1 {
209
+
s.pages.Notice(w, "remove", "Unable to find artifact.")
210
+
return
211
+
}
212
+
213
+
artifact := artifacts[0]
214
+
215
+
if user.Did != artifact.Did {
216
+
log.Println("user not authorized to delete artifact", err)
217
+
s.pages.Notice(w, "remove", "Unauthorized deletion of artifact.")
218
+
return
219
+
}
220
+
221
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
222
+
Collection: tangled.RepoArtifactNSID,
223
+
Repo: user.Did,
224
+
Rkey: artifact.Rkey,
225
+
})
226
+
if err != nil {
227
+
log.Println("failed to get blob from pds", err)
228
+
s.pages.Notice(w, "remove", "Failed to remove blob from PDS.")
229
+
return
230
+
}
231
+
232
+
tx, err := s.db.BeginTx(r.Context(), nil)
233
+
if err != nil {
234
+
log.Println("failed to start tx")
235
+
s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.")
236
+
return
237
+
}
238
+
defer tx.Rollback()
239
+
240
+
err = db.DeleteArtifact(tx,
241
+
db.Filter("repo_at", f.RepoAt),
242
+
db.Filter("tag", artifact.Tag[:]),
243
+
db.Filter("name", filename),
244
+
)
245
+
if err != nil {
246
+
log.Println("failed to remove artifact record from db", err)
247
+
s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.")
248
+
return
249
+
}
250
+
251
+
err = tx.Commit()
252
+
if err != nil {
253
+
log.Println("failed to remove artifact record from db")
254
+
s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.")
255
+
return
256
+
}
257
+
258
+
w.Write([]byte{})
259
+
}
260
+
261
+
func (s *State) resolveTag(f *FullyResolvedRepo, tagParam string) (*types.TagReference, error) {
262
+
tagParam, err := url.QueryUnescape(tagParam)
263
+
if err != nil {
264
+
return nil, err
265
+
}
266
+
267
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
268
+
if err != nil {
269
+
return nil, err
270
+
}
271
+
272
+
result, err := us.Tags(f.OwnerDid(), f.RepoName)
273
+
if err != nil {
274
+
log.Println("failed to reach knotserver", err)
275
+
return nil, err
276
+
}
277
+
278
+
var tag *types.TagReference
279
+
for _, t := range result.Tags {
280
+
if t.Tag != nil {
281
+
if t.Reference.Name == tagParam || t.Reference.Hash == tagParam {
282
+
tag = t
283
+
}
284
+
}
285
+
}
286
+
287
+
if tag == nil {
288
+
return nil, fmt.Errorf("invalid tag, only annotated tags are supported for artifacts")
289
+
}
290
+
291
+
if tag.Tag.Target.IsZero() {
292
+
return nil, fmt.Errorf("invalid tag, only annotated tags are supported for artifacts")
293
+
}
294
+
295
+
return tag, nil
296
+
}
+12
-7
appview/state/follow.go
+12
-7
appview/state/follow.go
···
7
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
-
tangled "tangled.sh/tangled.sh/core/api/tangled"
11
"tangled.sh/tangled.sh/core/appview/db"
12
"tangled.sh/tangled.sh/core/appview/pages"
13
)
14
15
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
16
-
currentUser := s.auth.GetUser(r)
17
18
subject := r.URL.Query().Get("subject")
19
if subject == "" {
···
31
return
32
}
33
34
-
client, _ := s.auth.AuthorizedClient(r)
35
36
switch r.Method {
37
case http.MethodPost:
38
createdAt := time.Now().Format(time.RFC3339)
39
-
rkey := s.TID()
40
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
41
Collection: tangled.GraphFollowNSID,
42
Repo: currentUser.Did,
43
Rkey: rkey,
···
74
return
75
}
76
77
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
78
Collection: tangled.GraphFollowNSID,
79
Repo: currentUser.Did,
80
Rkey: follow.Rkey,
···
85
return
86
}
87
88
-
err = db.DeleteFollow(s.db, currentUser.Did, subjectIdent.DID.String())
89
if err != nil {
90
log.Println("failed to delete follow from DB")
91
// this is not an issue, the firehose event might have already done this
···
7
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
+
"tangled.sh/tangled.sh/core/api/tangled"
11
+
"tangled.sh/tangled.sh/core/appview"
12
"tangled.sh/tangled.sh/core/appview/db"
13
"tangled.sh/tangled.sh/core/appview/pages"
14
)
15
16
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
17
+
currentUser := s.oauth.GetUser(r)
18
19
subject := r.URL.Query().Get("subject")
20
if subject == "" {
···
32
return
33
}
34
35
+
client, err := s.oauth.AuthorizedClient(r)
36
+
if err != nil {
37
+
log.Println("failed to authorize client")
38
+
return
39
+
}
40
41
switch r.Method {
42
case http.MethodPost:
43
createdAt := time.Now().Format(time.RFC3339)
44
+
rkey := appview.TID()
45
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
46
Collection: tangled.GraphFollowNSID,
47
Repo: currentUser.Did,
48
Rkey: rkey,
···
79
return
80
}
81
82
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
83
Collection: tangled.GraphFollowNSID,
84
Repo: currentUser.Did,
85
Rkey: follow.Rkey,
···
90
return
91
}
92
93
+
err = db.DeleteFollowByRkey(s.db, currentUser.Did, follow.Rkey)
94
if err != nil {
95
log.Println("failed to delete follow from DB")
96
// this is not an issue, the firehose event might have already done this
+2
-2
appview/state/git_http.go
+2
-2
appview/state/git_http.go
···
15
repo := chi.URLParam(r, "repo")
16
17
scheme := "https"
18
-
if s.config.Dev {
19
scheme = "http"
20
}
21
targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
···
52
repo := chi.URLParam(r, "repo")
53
54
scheme := "https"
55
-
if s.config.Dev {
56
scheme = "http"
57
}
58
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
···
15
repo := chi.URLParam(r, "repo")
16
17
scheme := "https"
18
+
if s.config.Core.Dev {
19
scheme = "http"
20
}
21
targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
···
52
repo := chi.URLParam(r, "repo")
53
54
scheme := "https"
55
+
if s.config.Core.Dev {
56
scheme = "http"
57
}
58
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
-70
appview/state/jetstream.go
-70
appview/state/jetstream.go
···
1
-
package state
2
-
3
-
import (
4
-
"context"
5
-
"encoding/json"
6
-
"fmt"
7
-
"log"
8
-
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"github.com/bluesky-social/jetstream/pkg/models"
11
-
tangled "tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/db"
13
-
)
14
-
15
-
type Ingester func(ctx context.Context, e *models.Event) error
16
-
17
-
func jetstreamIngester(d db.DbWrapper) Ingester {
18
-
return func(ctx context.Context, e *models.Event) error {
19
-
var err error
20
-
defer func() {
21
-
eventTime := e.TimeUS
22
-
lastTimeUs := eventTime + 1
23
-
if err := d.UpdateLastTimeUs(lastTimeUs); err != nil {
24
-
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
25
-
}
26
-
}()
27
-
28
-
if e.Kind != models.EventKindCommit {
29
-
return nil
30
-
}
31
-
32
-
did := e.Did
33
-
raw := json.RawMessage(e.Commit.Record)
34
-
35
-
switch e.Commit.Collection {
36
-
case tangled.GraphFollowNSID:
37
-
record := tangled.GraphFollow{}
38
-
err := json.Unmarshal(raw, &record)
39
-
if err != nil {
40
-
log.Println("invalid record")
41
-
return err
42
-
}
43
-
err = db.AddFollow(d, did, record.Subject, e.Commit.RKey)
44
-
if err != nil {
45
-
return fmt.Errorf("failed to add follow to db: %w", err)
46
-
}
47
-
case tangled.FeedStarNSID:
48
-
record := tangled.FeedStar{}
49
-
err := json.Unmarshal(raw, &record)
50
-
if err != nil {
51
-
log.Println("invalid record")
52
-
return err
53
-
}
54
-
55
-
subjectUri, err := syntax.ParseATURI(record.Subject)
56
-
57
-
if err != nil {
58
-
log.Println("invalid record")
59
-
return err
60
-
}
61
-
62
-
err = db.AddStar(d, did, subjectUri, e.Commit.RKey)
63
-
if err != nil {
64
-
return fmt.Errorf("failed to add follow to db: %w", err)
65
-
}
66
-
}
67
-
68
-
return err
69
-
}
70
-
}
···
+54
-98
appview/state/middleware.go
+54
-98
appview/state/middleware.go
···
2
3
import (
4
"context"
5
"log"
6
"net/http"
7
"strconv"
8
"strings"
9
"time"
10
11
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
12
"github.com/bluesky-social/indigo/atproto/identity"
13
-
"github.com/bluesky-social/indigo/xrpc"
14
"github.com/go-chi/chi/v5"
15
-
"tangled.sh/tangled.sh/core/appview"
16
-
"tangled.sh/tangled.sh/core/appview/auth"
17
"tangled.sh/tangled.sh/core/appview/db"
18
)
19
20
-
type Middleware func(http.Handler) http.Handler
21
-
22
-
func AuthMiddleware(s *State) Middleware {
23
-
return func(next http.Handler) http.Handler {
24
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25
-
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
26
-
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
27
-
}
28
-
if r.Header.Get("HX-Request") == "true" {
29
-
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
30
-
w.Header().Set("HX-Redirect", "/login")
31
-
w.WriteHeader(http.StatusOK)
32
-
}
33
-
}
34
-
35
-
session, err := s.auth.GetSession(r)
36
-
if session.IsNew || err != nil {
37
-
log.Printf("not logged in, redirecting")
38
-
redirectFunc(w, r)
39
-
return
40
-
}
41
-
42
-
authorized, ok := session.Values[appview.SessionAuthenticated].(bool)
43
-
if !ok || !authorized {
44
-
log.Printf("not logged in, redirecting")
45
-
redirectFunc(w, r)
46
-
return
47
-
}
48
-
49
-
// refresh if nearing expiry
50
-
// TODO: dedup with /login
51
-
expiryStr := session.Values[appview.SessionExpiry].(string)
52
-
expiry, err := time.Parse(time.RFC3339, expiryStr)
53
-
if err != nil {
54
-
log.Println("invalid expiry time", err)
55
-
redirectFunc(w, r)
56
-
return
57
-
}
58
-
pdsUrl, ok1 := session.Values[appview.SessionPds].(string)
59
-
did, ok2 := session.Values[appview.SessionDid].(string)
60
-
refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string)
61
-
62
-
if !ok1 || !ok2 || !ok3 {
63
-
log.Println("invalid expiry time", err)
64
-
redirectFunc(w, r)
65
-
return
66
-
}
67
-
68
-
if time.Now().After(expiry) {
69
-
log.Println("token expired, refreshing ...")
70
-
71
-
client := xrpc.Client{
72
-
Host: pdsUrl,
73
-
Auth: &xrpc.AuthInfo{
74
-
Did: did,
75
-
AccessJwt: refreshJwt,
76
-
RefreshJwt: refreshJwt,
77
-
},
78
-
}
79
-
atSession, err := comatproto.ServerRefreshSession(r.Context(), &client)
80
-
if err != nil {
81
-
log.Println("failed to refresh session", err)
82
-
redirectFunc(w, r)
83
-
return
84
-
}
85
-
86
-
sessionish := auth.RefreshSessionWrapper{atSession}
87
-
88
-
err = s.auth.StoreSession(r, w, &sessionish, pdsUrl)
89
-
if err != nil {
90
-
log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err)
91
-
return
92
-
}
93
-
94
-
log.Println("successfully refreshed token")
95
-
}
96
-
97
-
next.ServeHTTP(w, r)
98
-
})
99
-
}
100
-
}
101
-
102
-
func knotRoleMiddleware(s *State, group string) Middleware {
103
return func(next http.Handler) http.Handler {
104
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105
// requires auth also
106
-
actor := s.auth.GetUser(r)
107
if actor == nil {
108
// we need a logged in user
109
log.Printf("not logged in, redirecting")
···
129
}
130
}
131
132
-
func KnotOwner(s *State) Middleware {
133
return knotRoleMiddleware(s, "server:owner")
134
}
135
136
-
func RepoPermissionMiddleware(s *State, requiredPerm string) Middleware {
137
return func(next http.Handler) http.Handler {
138
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
139
// requires auth also
140
-
actor := s.auth.GetUser(r)
141
if actor == nil {
142
// we need a logged in user
143
log.Printf("not logged in, redirecting")
144
http.Error(w, "Forbiden", http.StatusUnauthorized)
145
return
146
}
147
-
f, err := fullyResolvedRepo(r)
148
if err != nil {
149
http.Error(w, "malformed url", http.StatusBadRequest)
150
return
151
}
152
153
-
ok, err := s.enforcer.E.Enforce(actor.Did, f.Knot, f.OwnerSlashRepo(), requiredPerm)
154
if err != nil || !ok {
155
// we need a logged in user
156
log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo())
···
173
})
174
}
175
176
-
func ResolveIdent(s *State) Middleware {
177
return func(next http.Handler) http.Handler {
178
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
179
didOrHandle := chi.URLParam(req, "user")
180
181
id, err := s.resolver.ResolveIdent(req.Context(), didOrHandle)
182
if err != nil {
···
193
}
194
}
195
196
-
func ResolveRepo(s *State) Middleware {
197
return func(next http.Handler) http.Handler {
198
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
199
repoName := chi.URLParam(req, "repo")
···
208
if err != nil {
209
// invalid did or handle
210
log.Println("failed to resolve repo")
211
-
w.WriteHeader(http.StatusNotFound)
212
return
213
}
214
···
222
}
223
224
// middleware that is tacked on top of /{user}/{repo}/pulls/{pull}
225
-
func ResolvePull(s *State) Middleware {
226
return func(next http.Handler) http.Handler {
227
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
228
-
f, err := fullyResolvedRepo(r)
229
if err != nil {
230
log.Println("failed to fully resolve repo", err)
231
http.Error(w, "invalid repo url", http.StatusNotFound)
···
252
})
253
}
254
}
···
2
3
import (
4
"context"
5
+
"fmt"
6
"log"
7
"net/http"
8
"strconv"
9
"strings"
10
"time"
11
12
+
"slices"
13
+
14
"github.com/bluesky-social/indigo/atproto/identity"
15
"github.com/go-chi/chi/v5"
16
"tangled.sh/tangled.sh/core/appview/db"
17
+
"tangled.sh/tangled.sh/core/appview/middleware"
18
)
19
20
+
func knotRoleMiddleware(s *State, group string) middleware.Middleware {
21
return func(next http.Handler) http.Handler {
22
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23
// requires auth also
24
+
actor := s.oauth.GetUser(r)
25
if actor == nil {
26
// we need a logged in user
27
log.Printf("not logged in, redirecting")
···
47
}
48
}
49
50
+
func KnotOwner(s *State) middleware.Middleware {
51
return knotRoleMiddleware(s, "server:owner")
52
}
53
54
+
func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middleware {
55
return func(next http.Handler) http.Handler {
56
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
57
// requires auth also
58
+
actor := s.oauth.GetUser(r)
59
if actor == nil {
60
// we need a logged in user
61
log.Printf("not logged in, redirecting")
62
http.Error(w, "Forbiden", http.StatusUnauthorized)
63
return
64
}
65
+
f, err := s.fullyResolvedRepo(r)
66
if err != nil {
67
http.Error(w, "malformed url", http.StatusBadRequest)
68
return
69
}
70
71
+
ok, err := s.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm)
72
if err != nil || !ok {
73
// we need a logged in user
74
log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo())
···
91
})
92
}
93
94
+
func ResolveIdent(s *State) middleware.Middleware {
95
+
excluded := []string{"favicon.ico"}
96
+
97
return func(next http.Handler) http.Handler {
98
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
99
didOrHandle := chi.URLParam(req, "user")
100
+
if slices.Contains(excluded, didOrHandle) {
101
+
next.ServeHTTP(w, req)
102
+
return
103
+
}
104
105
id, err := s.resolver.ResolveIdent(req.Context(), didOrHandle)
106
if err != nil {
···
117
}
118
}
119
120
+
func ResolveRepo(s *State) middleware.Middleware {
121
return func(next http.Handler) http.Handler {
122
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
123
repoName := chi.URLParam(req, "repo")
···
132
if err != nil {
133
// invalid did or handle
134
log.Println("failed to resolve repo")
135
+
s.pages.Error404(w)
136
return
137
}
138
···
146
}
147
148
// middleware that is tacked on top of /{user}/{repo}/pulls/{pull}
149
+
func ResolvePull(s *State) middleware.Middleware {
150
return func(next http.Handler) http.Handler {
151
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152
+
f, err := s.fullyResolvedRepo(r)
153
if err != nil {
154
log.Println("failed to fully resolve repo", err)
155
http.Error(w, "invalid repo url", http.StatusNotFound)
···
176
})
177
}
178
}
179
+
180
+
// this should serve the go-import meta tag even if the path is technically
181
+
// a 404 like tangled.sh/oppi.li/go-git/v5
182
+
func GoImport(s *State) middleware.Middleware {
183
+
return func(next http.Handler) http.Handler {
184
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
185
+
f, err := s.fullyResolvedRepo(r)
186
+
if err != nil {
187
+
log.Println("failed to fully resolve repo", err)
188
+
http.Error(w, "invalid repo url", http.StatusNotFound)
189
+
return
190
+
}
191
+
192
+
fullName := f.OwnerHandle() + "/" + f.RepoName
193
+
194
+
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
195
+
if r.URL.Query().Get("go-get") == "1" {
196
+
html := fmt.Sprintf(
197
+
`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`,
198
+
fullName,
199
+
fullName,
200
+
)
201
+
w.Header().Set("Content-Type", "text/html")
202
+
w.Write([]byte(html))
203
+
return
204
+
}
205
+
}
206
+
207
+
next.ServeHTTP(w, r)
208
+
})
209
+
}
210
+
}
+423
appview/state/profile.go
+423
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"
10
+
"slices"
11
+
"strings"
12
+
13
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
+
"github.com/bluesky-social/indigo/atproto/identity"
15
+
"github.com/bluesky-social/indigo/atproto/syntax"
16
+
lexutil "github.com/bluesky-social/indigo/lex/util"
17
+
"github.com/go-chi/chi/v5"
18
+
"tangled.sh/tangled.sh/core/api/tangled"
19
+
"tangled.sh/tangled.sh/core/appview/db"
20
+
"tangled.sh/tangled.sh/core/appview/pages"
21
+
)
22
+
23
+
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
24
+
tabVal := r.URL.Query().Get("tab")
25
+
switch tabVal {
26
+
case "":
27
+
s.profilePage(w, r)
28
+
case "repos":
29
+
s.reposPage(w, r)
30
+
}
31
+
}
32
+
33
+
func (s *State) profilePage(w http.ResponseWriter, r *http.Request) {
34
+
didOrHandle := chi.URLParam(r, "user")
35
+
if didOrHandle == "" {
36
+
http.Error(w, "Bad request", http.StatusBadRequest)
37
+
return
38
+
}
39
+
40
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
41
+
if !ok {
42
+
s.pages.Error404(w)
43
+
return
44
+
}
45
+
46
+
profile, err := db.GetProfile(s.db, ident.DID.String())
47
+
if err != nil {
48
+
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
49
+
}
50
+
51
+
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
52
+
if err != nil {
53
+
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
54
+
}
55
+
56
+
// filter out ones that are pinned
57
+
pinnedRepos := []db.Repo{}
58
+
for i, r := range repos {
59
+
// if this is a pinned repo, add it
60
+
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
61
+
pinnedRepos = append(pinnedRepos, r)
62
+
}
63
+
64
+
// if there are no saved pins, add the first 4 repos
65
+
if profile.IsPinnedReposEmpty() && i < 4 {
66
+
pinnedRepos = append(pinnedRepos, r)
67
+
}
68
+
}
69
+
70
+
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
71
+
if err != nil {
72
+
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
73
+
}
74
+
75
+
pinnedCollaboratingRepos := []db.Repo{}
76
+
for _, r := range collaboratingRepos {
77
+
// if this is a pinned repo, add it
78
+
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
79
+
pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r)
80
+
}
81
+
}
82
+
83
+
timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String())
84
+
if err != nil {
85
+
log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
86
+
}
87
+
88
+
var didsToResolve []string
89
+
for _, r := range collaboratingRepos {
90
+
didsToResolve = append(didsToResolve, r.Did)
91
+
}
92
+
for _, byMonth := range timeline.ByMonth {
93
+
for _, pe := range byMonth.PullEvents.Items {
94
+
didsToResolve = append(didsToResolve, pe.Repo.Did)
95
+
}
96
+
for _, ie := range byMonth.IssueEvents.Items {
97
+
didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
98
+
}
99
+
for _, re := range byMonth.RepoEvents {
100
+
didsToResolve = append(didsToResolve, re.Repo.Did)
101
+
if re.Source != nil {
102
+
didsToResolve = append(didsToResolve, re.Source.Did)
103
+
}
104
+
}
105
+
}
106
+
107
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
108
+
didHandleMap := make(map[string]string)
109
+
for _, identity := range resolvedIds {
110
+
if !identity.Handle.IsInvalidHandle() {
111
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
112
+
} else {
113
+
didHandleMap[identity.DID.String()] = identity.DID.String()
114
+
}
115
+
}
116
+
117
+
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
118
+
if err != nil {
119
+
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
120
+
}
121
+
122
+
loggedInUser := s.oauth.GetUser(r)
123
+
followStatus := db.IsNotFollowing
124
+
if loggedInUser != nil {
125
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
126
+
}
127
+
128
+
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
129
+
s.pages.ProfilePage(w, pages.ProfilePageParams{
130
+
LoggedInUser: loggedInUser,
131
+
Repos: pinnedRepos,
132
+
CollaboratingRepos: pinnedCollaboratingRepos,
133
+
DidHandleMap: didHandleMap,
134
+
Card: pages.ProfileCard{
135
+
UserDid: ident.DID.String(),
136
+
UserHandle: ident.Handle.String(),
137
+
AvatarUri: profileAvatarUri,
138
+
Profile: profile,
139
+
FollowStatus: followStatus,
140
+
Followers: followers,
141
+
Following: following,
142
+
},
143
+
ProfileTimeline: timeline,
144
+
})
145
+
}
146
+
147
+
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
148
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
149
+
if !ok {
150
+
s.pages.Error404(w)
151
+
return
152
+
}
153
+
154
+
profile, err := db.GetProfile(s.db, ident.DID.String())
155
+
if err != nil {
156
+
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
157
+
}
158
+
159
+
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
160
+
if err != nil {
161
+
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
162
+
}
163
+
164
+
loggedInUser := s.oauth.GetUser(r)
165
+
followStatus := db.IsNotFollowing
166
+
if loggedInUser != nil {
167
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
168
+
}
169
+
170
+
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
171
+
if err != nil {
172
+
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
173
+
}
174
+
175
+
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
176
+
177
+
s.pages.ReposPage(w, pages.ReposPageParams{
178
+
LoggedInUser: loggedInUser,
179
+
Repos: repos,
180
+
Card: pages.ProfileCard{
181
+
UserDid: ident.DID.String(),
182
+
UserHandle: ident.Handle.String(),
183
+
AvatarUri: profileAvatarUri,
184
+
Profile: profile,
185
+
FollowStatus: followStatus,
186
+
Followers: followers,
187
+
Following: following,
188
+
},
189
+
})
190
+
}
191
+
192
+
func (s *State) GetAvatarUri(handle string) string {
193
+
secret := s.config.Avatar.SharedSecret
194
+
h := hmac.New(sha256.New, []byte(secret))
195
+
h.Write([]byte(handle))
196
+
signature := hex.EncodeToString(h.Sum(nil))
197
+
return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle)
198
+
}
199
+
200
+
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
201
+
user := s.oauth.GetUser(r)
202
+
203
+
err := r.ParseForm()
204
+
if err != nil {
205
+
log.Println("invalid profile update form", err)
206
+
s.pages.Notice(w, "update-profile", "Invalid form.")
207
+
return
208
+
}
209
+
210
+
profile, err := db.GetProfile(s.db, user.Did)
211
+
if err != nil {
212
+
log.Printf("getting profile data for %s: %s", user.Did, err)
213
+
}
214
+
215
+
profile.Description = r.FormValue("description")
216
+
profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
217
+
profile.Location = r.FormValue("location")
218
+
219
+
var links [5]string
220
+
for i := range 5 {
221
+
iLink := r.FormValue(fmt.Sprintf("link%d", i))
222
+
links[i] = iLink
223
+
}
224
+
profile.Links = links
225
+
226
+
// Parse stats (exactly 2)
227
+
stat0 := r.FormValue("stat0")
228
+
stat1 := r.FormValue("stat1")
229
+
230
+
if stat0 != "" {
231
+
profile.Stats[0].Kind = db.VanityStatKind(stat0)
232
+
}
233
+
234
+
if stat1 != "" {
235
+
profile.Stats[1].Kind = db.VanityStatKind(stat1)
236
+
}
237
+
238
+
if err := db.ValidateProfile(s.db, profile); err != nil {
239
+
log.Println("invalid profile", err)
240
+
s.pages.Notice(w, "update-profile", err.Error())
241
+
return
242
+
}
243
+
244
+
s.updateProfile(profile, w, r)
245
+
return
246
+
}
247
+
248
+
func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
249
+
user := s.oauth.GetUser(r)
250
+
251
+
err := r.ParseForm()
252
+
if err != nil {
253
+
log.Println("invalid profile update form", err)
254
+
s.pages.Notice(w, "update-profile", "Invalid form.")
255
+
return
256
+
}
257
+
258
+
profile, err := db.GetProfile(s.db, user.Did)
259
+
if err != nil {
260
+
log.Printf("getting profile data for %s: %s", user.Did, err)
261
+
}
262
+
263
+
i := 0
264
+
var pinnedRepos [6]syntax.ATURI
265
+
for key, values := range r.Form {
266
+
if i >= 6 {
267
+
log.Println("invalid pin update form", err)
268
+
s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.")
269
+
return
270
+
}
271
+
if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 {
272
+
aturi, err := syntax.ParseATURI(values[0])
273
+
if err != nil {
274
+
log.Println("invalid profile update form", err)
275
+
s.pages.Notice(w, "update-profile", "Invalid form.")
276
+
return
277
+
}
278
+
pinnedRepos[i] = aturi
279
+
i++
280
+
}
281
+
}
282
+
profile.PinnedRepos = pinnedRepos
283
+
284
+
s.updateProfile(profile, w, r)
285
+
return
286
+
}
287
+
288
+
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
289
+
user := s.oauth.GetUser(r)
290
+
tx, err := s.db.BeginTx(r.Context(), nil)
291
+
if err != nil {
292
+
log.Println("failed to start transaction", err)
293
+
s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
294
+
return
295
+
}
296
+
297
+
client, err := s.oauth.AuthorizedClient(r)
298
+
if err != nil {
299
+
log.Println("failed to get authorized client", err)
300
+
s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
301
+
return
302
+
}
303
+
304
+
// yeah... lexgen dose not support syntax.ATURI in the record for some reason,
305
+
// nor does it support exact size arrays
306
+
var pinnedRepoStrings []string
307
+
for _, r := range profile.PinnedRepos {
308
+
pinnedRepoStrings = append(pinnedRepoStrings, r.String())
309
+
}
310
+
311
+
var vanityStats []string
312
+
for _, v := range profile.Stats {
313
+
vanityStats = append(vanityStats, string(v.Kind))
314
+
}
315
+
316
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self")
317
+
var cid *string
318
+
if ex != nil {
319
+
cid = ex.Cid
320
+
}
321
+
322
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
323
+
Collection: tangled.ActorProfileNSID,
324
+
Repo: user.Did,
325
+
Rkey: "self",
326
+
Record: &lexutil.LexiconTypeDecoder{
327
+
Val: &tangled.ActorProfile{
328
+
Bluesky: profile.IncludeBluesky,
329
+
Description: &profile.Description,
330
+
Links: profile.Links[:],
331
+
Location: &profile.Location,
332
+
PinnedRepositories: pinnedRepoStrings,
333
+
Stats: vanityStats[:],
334
+
}},
335
+
SwapRecord: cid,
336
+
})
337
+
if err != nil {
338
+
log.Println("failed to update profile", err)
339
+
s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.")
340
+
return
341
+
}
342
+
343
+
err = db.UpsertProfile(tx, profile)
344
+
if err != nil {
345
+
log.Println("failed to update profile", err)
346
+
s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
347
+
return
348
+
}
349
+
350
+
s.pages.HxRedirect(w, "/"+user.Did)
351
+
return
352
+
}
353
+
354
+
func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
355
+
user := s.oauth.GetUser(r)
356
+
357
+
profile, err := db.GetProfile(s.db, user.Did)
358
+
if err != nil {
359
+
log.Printf("getting profile data for %s: %s", user.Did, err)
360
+
}
361
+
362
+
s.pages.EditBioFragment(w, pages.EditBioParams{
363
+
LoggedInUser: user,
364
+
Profile: profile,
365
+
})
366
+
}
367
+
368
+
func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) {
369
+
user := s.oauth.GetUser(r)
370
+
371
+
profile, err := db.GetProfile(s.db, user.Did)
372
+
if err != nil {
373
+
log.Printf("getting profile data for %s: %s", user.Did, err)
374
+
}
375
+
376
+
repos, err := db.GetAllReposByDid(s.db, user.Did)
377
+
if err != nil {
378
+
log.Printf("getting repos for %s: %s", user.Did, err)
379
+
}
380
+
381
+
collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did)
382
+
if err != nil {
383
+
log.Printf("getting collaborating repos for %s: %s", user.Did, err)
384
+
}
385
+
386
+
allRepos := []pages.PinnedRepo{}
387
+
388
+
for _, r := range repos {
389
+
isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt())
390
+
allRepos = append(allRepos, pages.PinnedRepo{
391
+
IsPinned: isPinned,
392
+
Repo: r,
393
+
})
394
+
}
395
+
for _, r := range collaboratingRepos {
396
+
isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt())
397
+
allRepos = append(allRepos, pages.PinnedRepo{
398
+
IsPinned: isPinned,
399
+
Repo: r,
400
+
})
401
+
}
402
+
403
+
var didsToResolve []string
404
+
for _, r := range allRepos {
405
+
didsToResolve = append(didsToResolve, r.Did)
406
+
}
407
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
408
+
didHandleMap := make(map[string]string)
409
+
for _, identity := range resolvedIds {
410
+
if !identity.Handle.IsInvalidHandle() {
411
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
412
+
} else {
413
+
didHandleMap[identity.DID.String()] = identity.DID.String()
414
+
}
415
+
}
416
+
417
+
s.pages.EditPinsFragment(w, pages.EditPinsParams{
418
+
LoggedInUser: user,
419
+
Profile: profile,
420
+
AllRepos: allRepos,
421
+
DidHandleMap: didHandleMap,
422
+
})
423
+
}
+1068
-174
appview/state/pull.go
+1068
-174
appview/state/pull.go
···
1
package state
2
3
import (
4
"encoding/json"
5
"fmt"
6
"io"
7
"log"
8
"net/http"
9
"strconv"
10
-
"strings"
11
"time"
12
13
-
"github.com/go-chi/chi/v5"
14
"tangled.sh/tangled.sh/core/api/tangled"
15
"tangled.sh/tangled.sh/core/appview/db"
16
"tangled.sh/tangled.sh/core/appview/pages"
17
"tangled.sh/tangled.sh/core/types"
18
19
comatproto "github.com/bluesky-social/indigo/api/atproto"
20
lexutil "github.com/bluesky-social/indigo/lex/util"
21
)
22
23
// htmx fragment
24
func (s *State) PullActions(w http.ResponseWriter, r *http.Request) {
25
switch r.Method {
26
case http.MethodGet:
27
-
user := s.auth.GetUser(r)
28
-
f, err := fullyResolvedRepo(r)
29
if err != nil {
30
log.Println("failed to get repo and knot", err)
31
return
···
50
}
51
52
mergeCheckResponse := s.mergeCheck(f, pull)
53
54
s.pages.PullActionsFragment(w, pages.PullActionsParams{
55
-
LoggedInUser: user,
56
-
RepoInfo: f.RepoInfo(s, user),
57
-
Pull: pull,
58
-
RoundNumber: roundNumber,
59
-
MergeCheck: mergeCheckResponse,
60
})
61
return
62
}
63
}
64
65
func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
66
-
user := s.auth.GetUser(r)
67
-
f, err := fullyResolvedRepo(r)
68
if err != nil {
69
log.Println("failed to get repo and knot", err)
70
return
···
105
}
106
107
mergeCheckResponse := s.mergeCheck(f, pull)
108
109
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
110
-
LoggedInUser: user,
111
-
RepoInfo: f.RepoInfo(s, user),
112
-
DidHandleMap: didHandleMap,
113
-
Pull: *pull,
114
-
MergeCheck: mergeCheckResponse,
115
})
116
}
117
···
128
}
129
}
130
131
-
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
132
if err != nil {
133
log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
134
return types.MergeCheckResponse{
···
175
return mergeCheckResponse
176
}
177
178
func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
179
-
user := s.auth.GetUser(r)
180
-
f, err := fullyResolvedRepo(r)
181
if err != nil {
182
log.Println("failed to get repo and knot", err)
183
return
···
209
}
210
}
211
212
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
213
LoggedInUser: user,
214
DidHandleMap: didHandleMap,
···
216
Pull: pull,
217
Round: roundIdInt,
218
Submission: pull.Submissions[roundIdInt],
219
-
Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch),
220
})
221
222
}
223
224
func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
225
-
user := s.auth.GetUser(r)
226
params := r.URL.Query()
227
228
state := db.PullOpen
···
233
state = db.PullMerged
234
}
235
236
-
f, err := fullyResolvedRepo(r)
237
if err != nil {
238
log.Println("failed to get repo and knot", err)
239
return
···
246
return
247
}
248
249
identsToResolve := make([]string, len(pulls))
250
for i, pull := range pulls {
251
identsToResolve[i] = pull.OwnerDid
···
261
}
262
263
s.pages.RepoPulls(w, pages.RepoPullsParams{
264
-
LoggedInUser: s.auth.GetUser(r),
265
RepoInfo: f.RepoInfo(s, user),
266
Pulls: pulls,
267
DidHandleMap: didHandleMap,
···
271
}
272
273
func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
274
-
user := s.auth.GetUser(r)
275
-
f, err := fullyResolvedRepo(r)
276
if err != nil {
277
log.Println("failed to get repo and knot", err)
278
return
···
329
}
330
331
atUri := f.RepoAt.String()
332
-
client, _ := s.auth.AuthorizedClient(r)
333
-
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
334
Collection: tangled.RepoPullCommentNSID,
335
Repo: user.Did,
336
-
Rkey: s.TID(),
337
Record: &lexutil.LexiconTypeDecoder{
338
Val: &tangled.RepoPullComment{
339
Repo: &atUri,
340
-
Pull: pullAt,
341
Owner: &ownerDid,
342
-
Body: &body,
343
-
CreatedAt: &createdAt,
344
},
345
},
346
})
347
-
log.Println(atResp.Uri)
348
if err != nil {
349
log.Println("failed to create pull comment", err)
350
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
379
}
380
381
func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
382
-
user := s.auth.GetUser(r)
383
-
f, err := fullyResolvedRepo(r)
384
if err != nil {
385
log.Println("failed to get repo and knot", err)
386
return
···
388
389
switch r.Method {
390
case http.MethodGet:
391
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
392
if err != nil {
393
log.Printf("failed to create unsigned client for %s", f.Knot)
394
s.pages.Error503(w)
···
423
title := r.FormValue("title")
424
body := r.FormValue("body")
425
targetBranch := r.FormValue("targetBranch")
426
patch := r.FormValue("patch")
427
428
-
if title == "" || body == "" || patch == "" || targetBranch == "" {
429
-
s.pages.Notice(w, "pull", "Title, body and patch diff are required.")
430
return
431
}
432
433
-
// Validate patch format
434
-
if !isPatchValid(patch) {
435
-
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
436
return
437
}
438
439
-
tx, err := s.db.BeginTx(r.Context(), nil)
440
if err != nil {
441
-
log.Println("failed to start tx")
442
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
443
return
444
}
445
-
defer tx.Rollback()
446
447
-
rkey := s.TID()
448
-
initialSubmission := db.PullSubmission{
449
-
Patch: patch,
450
-
}
451
-
err = db.NewPull(tx, &db.Pull{
452
-
Title: title,
453
-
Body: body,
454
-
TargetBranch: targetBranch,
455
-
OwnerDid: user.Did,
456
-
RepoAt: f.RepoAt,
457
-
Rkey: rkey,
458
-
Submissions: []*db.PullSubmission{
459
-
&initialSubmission,
460
-
},
461
-
})
462
if err != nil {
463
-
log.Println("failed to create pull request", err)
464
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
465
return
466
}
467
-
client, _ := s.auth.AuthorizedClient(r)
468
-
pullId, err := db.NextPullId(s.db, f.RepoAt)
469
-
if err != nil {
470
-
log.Println("failed to get pull id", err)
471
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
472
return
473
}
474
475
-
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
476
-
Collection: tangled.RepoPullNSID,
477
-
Repo: user.Did,
478
-
Rkey: rkey,
479
-
Record: &lexutil.LexiconTypeDecoder{
480
-
Val: &tangled.RepoPull{
481
-
Title: title,
482
-
PullId: int64(pullId),
483
-
TargetRepo: string(f.RepoAt),
484
-
TargetBranch: targetBranch,
485
-
Patch: patch,
486
-
},
487
-
},
488
-
})
489
490
-
err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
491
if err != nil {
492
-
log.Println("failed to get pull id", err)
493
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
494
return
495
}
496
497
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
498
return
499
}
500
}
501
502
func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
503
-
user := s.auth.GetUser(r)
504
-
f, err := fullyResolvedRepo(r)
505
if err != nil {
506
log.Println("failed to get repo and knot", err)
507
return
···
522
})
523
return
524
case http.MethodPost:
525
-
patch := r.FormValue("patch")
526
-
527
-
if patch == "" {
528
-
s.pages.Notice(w, "resubmit-error", "Patch is empty.")
529
return
530
}
531
532
-
if patch == pull.LatestPatch() {
533
-
s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
534
-
return
535
-
}
536
537
-
// Validate patch format
538
-
if !isPatchValid(patch) {
539
-
s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.")
540
-
return
541
-
}
542
543
-
tx, err := s.db.BeginTx(r.Context(), nil)
544
-
if err != nil {
545
-
log.Println("failed to start tx")
546
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
547
-
return
548
-
}
549
-
defer tx.Rollback()
550
551
-
err = db.ResubmitPull(tx, pull, patch)
552
-
if err != nil {
553
-
log.Println("failed to create pull request", err)
554
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
555
-
return
556
-
}
557
-
client, _ := s.auth.AuthorizedClient(r)
558
559
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
560
-
if err != nil {
561
-
// failed to get record
562
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
563
-
return
564
-
}
565
566
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
567
-
Collection: tangled.RepoPullNSID,
568
-
Repo: user.Did,
569
-
Rkey: pull.Rkey,
570
-
SwapRecord: ex.Cid,
571
-
Record: &lexutil.LexiconTypeDecoder{
572
-
Val: &tangled.RepoPull{
573
-
Title: pull.Title,
574
-
PullId: int64(pull.PullId),
575
-
TargetRepo: string(f.RepoAt),
576
-
TargetBranch: pull.TargetBranch,
577
-
Patch: patch, // new patch
578
-
},
579
},
580
-
})
581
-
if err != nil {
582
-
log.Println("failed to update record", err)
583
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
584
-
return
585
-
}
586
587
-
if err = tx.Commit(); err != nil {
588
-
log.Println("failed to commit transaction", err)
589
-
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
590
-
return
591
-
}
592
593
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
594
return
595
}
596
}
597
598
func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
599
-
f, err := fullyResolvedRepo(r)
600
if err != nil {
601
log.Println("failed to resolve repo:", err)
602
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
617
return
618
}
619
620
-
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
621
if err != nil {
622
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
623
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
625
}
626
627
// Merge the pull request
628
-
resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, "", "")
629
if err != nil {
630
log.Printf("failed to merge pull request: %s", err)
631
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
647
}
648
649
func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
650
-
user := s.auth.GetUser(r)
651
652
-
f, err := fullyResolvedRepo(r)
653
if err != nil {
654
log.Println("malformed middleware")
655
return
···
701
}
702
703
func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
704
-
user := s.auth.GetUser(r)
705
706
-
f, err := fullyResolvedRepo(r)
707
if err != nil {
708
log.Println("failed to resolve repo", err)
709
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
···
754
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
755
return
756
}
757
-
758
-
// Very basic validation to check if it looks like a diff/patch
759
-
// A valid patch usually starts with diff or --- lines
760
-
func isPatchValid(patch string) bool {
761
-
// Basic validation to check if it looks like a diff/patch
762
-
// A valid patch usually starts with diff or --- lines
763
-
if len(patch) == 0 {
764
-
return false
765
-
}
766
-
767
-
lines := strings.Split(patch, "\n")
768
-
if len(lines) < 2 {
769
-
return false
770
-
}
771
-
772
-
// Check for common patch format markers
773
-
firstLine := strings.TrimSpace(lines[0])
774
-
return strings.HasPrefix(firstLine, "diff ") ||
775
-
strings.HasPrefix(firstLine, "--- ") ||
776
-
strings.HasPrefix(firstLine, "Index: ") ||
777
-
strings.HasPrefix(firstLine, "+++ ") ||
778
-
strings.HasPrefix(firstLine, "@@ ")
779
-
}
···
1
package state
2
3
import (
4
+
"database/sql"
5
"encoding/json"
6
+
"errors"
7
"fmt"
8
"io"
9
"log"
10
"net/http"
11
"strconv"
12
"time"
13
14
"tangled.sh/tangled.sh/core/api/tangled"
15
+
"tangled.sh/tangled.sh/core/appview"
16
"tangled.sh/tangled.sh/core/appview/db"
17
+
"tangled.sh/tangled.sh/core/appview/knotclient"
18
+
"tangled.sh/tangled.sh/core/appview/oauth"
19
"tangled.sh/tangled.sh/core/appview/pages"
20
+
"tangled.sh/tangled.sh/core/patchutil"
21
"tangled.sh/tangled.sh/core/types"
22
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
+
"github.com/bluesky-social/indigo/atproto/syntax"
25
lexutil "github.com/bluesky-social/indigo/lex/util"
26
+
"github.com/go-chi/chi/v5"
27
)
28
29
// htmx fragment
30
func (s *State) PullActions(w http.ResponseWriter, r *http.Request) {
31
switch r.Method {
32
case http.MethodGet:
33
+
user := s.oauth.GetUser(r)
34
+
f, err := s.fullyResolvedRepo(r)
35
if err != nil {
36
log.Println("failed to get repo and knot", err)
37
return
···
56
}
57
58
mergeCheckResponse := s.mergeCheck(f, pull)
59
+
resubmitResult := pages.Unknown
60
+
if user.Did == pull.OwnerDid {
61
+
resubmitResult = s.resubmitCheck(f, pull)
62
+
}
63
64
s.pages.PullActionsFragment(w, pages.PullActionsParams{
65
+
LoggedInUser: user,
66
+
RepoInfo: f.RepoInfo(s, user),
67
+
Pull: pull,
68
+
RoundNumber: roundNumber,
69
+
MergeCheck: mergeCheckResponse,
70
+
ResubmitCheck: resubmitResult,
71
})
72
return
73
}
74
}
75
76
func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
77
+
user := s.oauth.GetUser(r)
78
+
f, err := s.fullyResolvedRepo(r)
79
if err != nil {
80
log.Println("failed to get repo and knot", err)
81
return
···
116
}
117
118
mergeCheckResponse := s.mergeCheck(f, pull)
119
+
resubmitResult := pages.Unknown
120
+
if user != nil && user.Did == pull.OwnerDid {
121
+
resubmitResult = s.resubmitCheck(f, pull)
122
+
}
123
124
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
125
+
LoggedInUser: user,
126
+
RepoInfo: f.RepoInfo(s, user),
127
+
DidHandleMap: didHandleMap,
128
+
Pull: pull,
129
+
MergeCheck: mergeCheckResponse,
130
+
ResubmitCheck: resubmitResult,
131
})
132
}
133
···
144
}
145
}
146
147
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
148
if err != nil {
149
log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
150
return types.MergeCheckResponse{
···
191
return mergeCheckResponse
192
}
193
194
+
func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult {
195
+
if pull.State == db.PullMerged || pull.PullSource == nil {
196
+
return pages.Unknown
197
+
}
198
+
199
+
var knot, ownerDid, repoName string
200
+
201
+
if pull.PullSource.RepoAt != nil {
202
+
// fork-based pulls
203
+
sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
204
+
if err != nil {
205
+
log.Println("failed to get source repo", err)
206
+
return pages.Unknown
207
+
}
208
+
209
+
knot = sourceRepo.Knot
210
+
ownerDid = sourceRepo.Did
211
+
repoName = sourceRepo.Name
212
+
} else {
213
+
// pulls within the same repo
214
+
knot = f.Knot
215
+
ownerDid = f.OwnerDid()
216
+
repoName = f.RepoName
217
+
}
218
+
219
+
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
220
+
if err != nil {
221
+
log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
222
+
return pages.Unknown
223
+
}
224
+
225
+
resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
226
+
if err != nil {
227
+
log.Println("failed to reach knotserver", err)
228
+
return pages.Unknown
229
+
}
230
+
231
+
body, err := io.ReadAll(resp.Body)
232
+
if err != nil {
233
+
log.Printf("error reading response body: %v", err)
234
+
return pages.Unknown
235
+
}
236
+
defer resp.Body.Close()
237
+
238
+
var result types.RepoBranchResponse
239
+
if err := json.Unmarshal(body, &result); err != nil {
240
+
log.Println("failed to parse response:", err)
241
+
return pages.Unknown
242
+
}
243
+
244
+
latestSubmission := pull.Submissions[pull.LastRoundNumber()]
245
+
if latestSubmission.SourceRev != result.Branch.Hash {
246
+
fmt.Println(latestSubmission.SourceRev, result.Branch.Hash)
247
+
return pages.ShouldResubmit
248
+
}
249
+
250
+
return pages.ShouldNotResubmit
251
+
}
252
+
253
func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
254
+
user := s.oauth.GetUser(r)
255
+
f, err := s.fullyResolvedRepo(r)
256
if err != nil {
257
log.Println("failed to get repo and knot", err)
258
return
···
284
}
285
}
286
287
+
diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch)
288
+
289
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
290
LoggedInUser: user,
291
DidHandleMap: didHandleMap,
···
293
Pull: pull,
294
Round: roundIdInt,
295
Submission: pull.Submissions[roundIdInt],
296
+
Diff: &diff,
297
+
})
298
+
299
+
}
300
+
301
+
func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
302
+
user := s.oauth.GetUser(r)
303
+
304
+
f, err := s.fullyResolvedRepo(r)
305
+
if err != nil {
306
+
log.Println("failed to get repo and knot", err)
307
+
return
308
+
}
309
+
310
+
pull, ok := r.Context().Value("pull").(*db.Pull)
311
+
if !ok {
312
+
log.Println("failed to get pull")
313
+
s.pages.Notice(w, "pull-error", "Failed to get pull.")
314
+
return
315
+
}
316
+
317
+
roundId := chi.URLParam(r, "round")
318
+
roundIdInt, err := strconv.Atoi(roundId)
319
+
if err != nil || roundIdInt >= len(pull.Submissions) {
320
+
http.Error(w, "bad round id", http.StatusBadRequest)
321
+
log.Println("failed to parse round id", err)
322
+
return
323
+
}
324
+
325
+
if roundIdInt == 0 {
326
+
http.Error(w, "bad round id", http.StatusBadRequest)
327
+
log.Println("cannot interdiff initial submission")
328
+
return
329
+
}
330
+
331
+
identsToResolve := []string{pull.OwnerDid}
332
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
333
+
didHandleMap := make(map[string]string)
334
+
for _, identity := range resolvedIds {
335
+
if !identity.Handle.IsInvalidHandle() {
336
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
337
+
} else {
338
+
didHandleMap[identity.DID.String()] = identity.DID.String()
339
+
}
340
+
}
341
+
342
+
currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch)
343
+
if err != nil {
344
+
log.Println("failed to interdiff; current patch malformed")
345
+
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
346
+
return
347
+
}
348
+
349
+
previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch)
350
+
if err != nil {
351
+
log.Println("failed to interdiff; previous patch malformed")
352
+
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
353
+
return
354
+
}
355
+
356
+
interdiff := patchutil.Interdiff(previousPatch, currentPatch)
357
+
358
+
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
359
+
LoggedInUser: s.oauth.GetUser(r),
360
+
RepoInfo: f.RepoInfo(s, user),
361
+
Pull: pull,
362
+
Round: roundIdInt,
363
+
DidHandleMap: didHandleMap,
364
+
Interdiff: interdiff,
365
})
366
+
return
367
+
}
368
+
369
+
func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
370
+
pull, ok := r.Context().Value("pull").(*db.Pull)
371
+
if !ok {
372
+
log.Println("failed to get pull")
373
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
374
+
return
375
+
}
376
377
+
roundId := chi.URLParam(r, "round")
378
+
roundIdInt, err := strconv.Atoi(roundId)
379
+
if err != nil || roundIdInt >= len(pull.Submissions) {
380
+
http.Error(w, "bad round id", http.StatusBadRequest)
381
+
log.Println("failed to parse round id", err)
382
+
return
383
+
}
384
+
385
+
identsToResolve := []string{pull.OwnerDid}
386
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
387
+
didHandleMap := make(map[string]string)
388
+
for _, identity := range resolvedIds {
389
+
if !identity.Handle.IsInvalidHandle() {
390
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
391
+
} else {
392
+
didHandleMap[identity.DID.String()] = identity.DID.String()
393
+
}
394
+
}
395
+
396
+
w.Header().Set("Content-Type", "text/plain")
397
+
w.Write([]byte(pull.Submissions[roundIdInt].Patch))
398
}
399
400
func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
401
+
user := s.oauth.GetUser(r)
402
params := r.URL.Query()
403
404
state := db.PullOpen
···
409
state = db.PullMerged
410
}
411
412
+
f, err := s.fullyResolvedRepo(r)
413
if err != nil {
414
log.Println("failed to get repo and knot", err)
415
return
···
422
return
423
}
424
425
+
for _, p := range pulls {
426
+
var pullSourceRepo *db.Repo
427
+
if p.PullSource != nil {
428
+
if p.PullSource.RepoAt != nil {
429
+
pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
430
+
if err != nil {
431
+
log.Printf("failed to get repo by at uri: %v", err)
432
+
continue
433
+
} else {
434
+
p.PullSource.Repo = pullSourceRepo
435
+
}
436
+
}
437
+
}
438
+
}
439
+
440
identsToResolve := make([]string, len(pulls))
441
for i, pull := range pulls {
442
identsToResolve[i] = pull.OwnerDid
···
452
}
453
454
s.pages.RepoPulls(w, pages.RepoPullsParams{
455
+
LoggedInUser: s.oauth.GetUser(r),
456
RepoInfo: f.RepoInfo(s, user),
457
Pulls: pulls,
458
DidHandleMap: didHandleMap,
···
462
}
463
464
func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
465
+
user := s.oauth.GetUser(r)
466
+
f, err := s.fullyResolvedRepo(r)
467
if err != nil {
468
log.Println("failed to get repo and knot", err)
469
return
···
520
}
521
522
atUri := f.RepoAt.String()
523
+
client, err := s.oauth.AuthorizedClient(r)
524
+
if err != nil {
525
+
log.Println("failed to get authorized client", err)
526
+
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
527
+
return
528
+
}
529
+
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
530
Collection: tangled.RepoPullCommentNSID,
531
Repo: user.Did,
532
+
Rkey: appview.TID(),
533
Record: &lexutil.LexiconTypeDecoder{
534
Val: &tangled.RepoPullComment{
535
Repo: &atUri,
536
+
Pull: string(pullAt),
537
Owner: &ownerDid,
538
+
Body: body,
539
+
CreatedAt: createdAt,
540
},
541
},
542
})
543
if err != nil {
544
log.Println("failed to create pull comment", err)
545
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
574
}
575
576
func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
577
+
user := s.oauth.GetUser(r)
578
+
f, err := s.fullyResolvedRepo(r)
579
if err != nil {
580
log.Println("failed to get repo and knot", err)
581
return
···
583
584
switch r.Method {
585
case http.MethodGet:
586
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
587
if err != nil {
588
log.Printf("failed to create unsigned client for %s", f.Knot)
589
s.pages.Error503(w)
···
618
title := r.FormValue("title")
619
body := r.FormValue("body")
620
targetBranch := r.FormValue("targetBranch")
621
+
fromFork := r.FormValue("fork")
622
+
sourceBranch := r.FormValue("sourceBranch")
623
patch := r.FormValue("patch")
624
625
+
if targetBranch == "" {
626
+
s.pages.Notice(w, "pull", "Target branch is required.")
627
+
return
628
+
}
629
+
630
+
// Determine PR type based on input parameters
631
+
isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
632
+
isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
633
+
isForkBased := fromFork != "" && sourceBranch != ""
634
+
isPatchBased := patch != "" && !isBranchBased && !isForkBased
635
+
636
+
if isPatchBased && !patchutil.IsFormatPatch(patch) {
637
+
if title == "" {
638
+
s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
639
+
return
640
+
}
641
+
}
642
+
643
+
// Validate we have at least one valid PR creation method
644
+
if !isBranchBased && !isPatchBased && !isForkBased {
645
+
s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
646
return
647
}
648
649
+
// Can't mix branch-based and patch-based approaches
650
+
if isBranchBased && patch != "" {
651
+
s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
652
return
653
}
654
655
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
656
if err != nil {
657
+
log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
658
+
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
659
return
660
}
661
662
+
caps, err := us.Capabilities()
663
if err != nil {
664
+
log.Println("error fetching knot caps", f.Knot, err)
665
+
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
666
return
667
}
668
+
669
+
if !caps.PullRequests.FormatPatch {
670
+
s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
671
return
672
}
673
674
+
// Handle the PR creation based on the type
675
+
if isBranchBased {
676
+
if !caps.PullRequests.BranchSubmissions {
677
+
s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
678
+
return
679
+
}
680
+
s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch)
681
+
} else if isForkBased {
682
+
if !caps.PullRequests.ForkSubmissions {
683
+
s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
684
+
return
685
+
}
686
+
s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch)
687
+
} else if isPatchBased {
688
+
if !caps.PullRequests.PatchSubmissions {
689
+
s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
690
+
return
691
+
}
692
+
s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch)
693
+
}
694
+
return
695
+
}
696
+
}
697
+
698
+
func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, title, body, targetBranch, sourceBranch string) {
699
+
pullSource := &db.PullSource{
700
+
Branch: sourceBranch,
701
+
}
702
+
recordPullSource := &tangled.RepoPull_Source{
703
+
Branch: sourceBranch,
704
+
}
705
+
706
+
// Generate a patch using /compare
707
+
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
708
+
if err != nil {
709
+
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
710
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
711
+
return
712
+
}
713
+
714
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
715
+
if err != nil {
716
+
log.Println("failed to compare", err)
717
+
s.pages.Notice(w, "pull", err.Error())
718
+
return
719
+
}
720
+
721
+
sourceRev := comparison.Rev2
722
+
patch := comparison.Patch
723
+
724
+
if !patchutil.IsPatchValid(patch) {
725
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
726
+
return
727
+
}
728
+
729
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource)
730
+
}
731
+
732
+
func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, title, body, targetBranch, patch string) {
733
+
if !patchutil.IsPatchValid(patch) {
734
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
735
+
return
736
+
}
737
+
738
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil)
739
+
}
740
+
741
+
func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string) {
742
+
fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
743
+
if errors.Is(err, sql.ErrNoRows) {
744
+
s.pages.Notice(w, "pull", "No such fork.")
745
+
return
746
+
} else if err != nil {
747
+
log.Println("failed to fetch fork:", err)
748
+
s.pages.Notice(w, "pull", "Failed to fetch fork.")
749
+
return
750
+
}
751
+
752
+
secret, err := db.GetRegistrationKey(s.db, fork.Knot)
753
+
if err != nil {
754
+
log.Println("failed to fetch registration key:", err)
755
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
756
+
return
757
+
}
758
+
759
+
sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev)
760
+
if err != nil {
761
+
log.Println("failed to create signed client:", err)
762
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
763
+
return
764
+
}
765
+
766
+
us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
767
+
if err != nil {
768
+
log.Println("failed to create unsigned client:", err)
769
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
770
+
return
771
+
}
772
773
+
resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
774
+
if err != nil {
775
+
log.Println("failed to create hidden ref:", err, resp.StatusCode)
776
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
777
+
return
778
+
}
779
+
780
+
switch resp.StatusCode {
781
+
case 404:
782
+
case 400:
783
+
s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
784
+
return
785
+
}
786
+
787
+
hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)
788
+
// We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
789
+
// the targetBranch on the target repository. This code is a bit confusing, but here's an example:
790
+
// hiddenRef: hidden/feature-1/main (on repo-fork)
791
+
// targetBranch: main (on repo-1)
792
+
// sourceBranch: feature-1 (on repo-fork)
793
+
comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
794
+
if err != nil {
795
+
log.Println("failed to compare across branches", err)
796
+
s.pages.Notice(w, "pull", err.Error())
797
+
return
798
+
}
799
+
800
+
sourceRev := comparison.Rev2
801
+
patch := comparison.Patch
802
+
803
+
if !patchutil.IsPatchValid(patch) {
804
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
805
+
return
806
+
}
807
+
808
+
forkAtUri, err := syntax.ParseATURI(fork.AtUri)
809
+
if err != nil {
810
+
log.Println("failed to parse fork AT URI", err)
811
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
812
+
return
813
+
}
814
+
815
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
816
+
Branch: sourceBranch,
817
+
RepoAt: &forkAtUri,
818
+
}, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri})
819
+
}
820
+
821
+
func (s *State) createPullRequest(
822
+
w http.ResponseWriter,
823
+
r *http.Request,
824
+
f *FullyResolvedRepo,
825
+
user *oauth.User,
826
+
title, body, targetBranch string,
827
+
patch string,
828
+
sourceRev string,
829
+
pullSource *db.PullSource,
830
+
recordPullSource *tangled.RepoPull_Source,
831
+
) {
832
+
tx, err := s.db.BeginTx(r.Context(), nil)
833
+
if err != nil {
834
+
log.Println("failed to start tx")
835
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
836
+
return
837
+
}
838
+
defer tx.Rollback()
839
+
840
+
// We've already checked earlier if it's diff-based and title is empty,
841
+
// so if it's still empty now, it's intentionally skipped owing to format-patch.
842
+
if title == "" {
843
+
formatPatches, err := patchutil.ExtractPatches(patch)
844
if err != nil {
845
+
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
846
+
return
847
+
}
848
+
if len(formatPatches) == 0 {
849
+
s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
850
return
851
}
852
853
+
title = formatPatches[0].Title
854
+
body = formatPatches[0].Body
855
+
}
856
+
857
+
rkey := appview.TID()
858
+
initialSubmission := db.PullSubmission{
859
+
Patch: patch,
860
+
SourceRev: sourceRev,
861
+
}
862
+
err = db.NewPull(tx, &db.Pull{
863
+
Title: title,
864
+
Body: body,
865
+
TargetBranch: targetBranch,
866
+
OwnerDid: user.Did,
867
+
RepoAt: f.RepoAt,
868
+
Rkey: rkey,
869
+
Submissions: []*db.PullSubmission{
870
+
&initialSubmission,
871
+
},
872
+
PullSource: pullSource,
873
+
})
874
+
if err != nil {
875
+
log.Println("failed to create pull request", err)
876
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
877
+
return
878
+
}
879
+
client, err := s.oauth.AuthorizedClient(r)
880
+
if err != nil {
881
+
log.Println("failed to get authorized client", err)
882
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
883
+
return
884
+
}
885
+
pullId, err := db.NextPullId(tx, f.RepoAt)
886
+
if err != nil {
887
+
log.Println("failed to get pull id", err)
888
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
889
+
return
890
+
}
891
+
892
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
893
+
Collection: tangled.RepoPullNSID,
894
+
Repo: user.Did,
895
+
Rkey: rkey,
896
+
Record: &lexutil.LexiconTypeDecoder{
897
+
Val: &tangled.RepoPull{
898
+
Title: title,
899
+
PullId: int64(pullId),
900
+
TargetRepo: string(f.RepoAt),
901
+
TargetBranch: targetBranch,
902
+
Patch: patch,
903
+
Source: recordPullSource,
904
+
},
905
+
},
906
+
})
907
+
if err != nil {
908
+
log.Println("failed to create pull request", err)
909
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
910
+
return
911
+
}
912
+
913
+
if err = tx.Commit(); err != nil {
914
+
log.Println("failed to create pull request", err)
915
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
916
return
917
}
918
+
919
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
920
+
}
921
+
922
+
func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) {
923
+
_, err := s.fullyResolvedRepo(r)
924
+
if err != nil {
925
+
log.Println("failed to get repo and knot", err)
926
+
return
927
+
}
928
+
929
+
patch := r.FormValue("patch")
930
+
if patch == "" {
931
+
s.pages.Notice(w, "patch-error", "Patch is required.")
932
+
return
933
+
}
934
+
935
+
if patch == "" || !patchutil.IsPatchValid(patch) {
936
+
s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
937
+
return
938
+
}
939
+
940
+
if patchutil.IsFormatPatch(patch) {
941
+
s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.")
942
+
} else {
943
+
s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
944
+
}
945
+
}
946
+
947
+
func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
948
+
user := s.oauth.GetUser(r)
949
+
f, err := s.fullyResolvedRepo(r)
950
+
if err != nil {
951
+
log.Println("failed to get repo and knot", err)
952
+
return
953
+
}
954
+
955
+
s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
956
+
RepoInfo: f.RepoInfo(s, user),
957
+
})
958
+
}
959
+
960
+
func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
961
+
user := s.oauth.GetUser(r)
962
+
f, err := s.fullyResolvedRepo(r)
963
+
if err != nil {
964
+
log.Println("failed to get repo and knot", err)
965
+
return
966
+
}
967
+
968
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
969
+
if err != nil {
970
+
log.Printf("failed to create unsigned client for %s", f.Knot)
971
+
s.pages.Error503(w)
972
+
return
973
+
}
974
+
975
+
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
976
+
if err != nil {
977
+
log.Println("failed to reach knotserver", err)
978
+
return
979
+
}
980
+
981
+
body, err := io.ReadAll(resp.Body)
982
+
if err != nil {
983
+
log.Printf("Error reading response body: %v", err)
984
+
return
985
+
}
986
+
987
+
var result types.RepoBranchesResponse
988
+
err = json.Unmarshal(body, &result)
989
+
if err != nil {
990
+
log.Println("failed to parse response:", err)
991
+
return
992
+
}
993
+
994
+
s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
995
+
RepoInfo: f.RepoInfo(s, user),
996
+
Branches: result.Branches,
997
+
})
998
+
}
999
+
1000
+
func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1001
+
user := s.oauth.GetUser(r)
1002
+
f, err := s.fullyResolvedRepo(r)
1003
+
if err != nil {
1004
+
log.Println("failed to get repo and knot", err)
1005
+
return
1006
+
}
1007
+
1008
+
forks, err := db.GetForksByDid(s.db, user.Did)
1009
+
if err != nil {
1010
+
log.Println("failed to get forks", err)
1011
+
return
1012
+
}
1013
+
1014
+
s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1015
+
RepoInfo: f.RepoInfo(s, user),
1016
+
Forks: forks,
1017
+
})
1018
+
}
1019
+
1020
+
func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1021
+
user := s.oauth.GetUser(r)
1022
+
1023
+
f, err := s.fullyResolvedRepo(r)
1024
+
if err != nil {
1025
+
log.Println("failed to get repo and knot", err)
1026
+
return
1027
+
}
1028
+
1029
+
forkVal := r.URL.Query().Get("fork")
1030
+
1031
+
// fork repo
1032
+
repo, err := db.GetRepo(s.db, user.Did, forkVal)
1033
+
if err != nil {
1034
+
log.Println("failed to get repo", user.Did, forkVal)
1035
+
return
1036
+
}
1037
+
1038
+
sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev)
1039
+
if err != nil {
1040
+
log.Printf("failed to create unsigned client for %s", repo.Knot)
1041
+
s.pages.Error503(w)
1042
+
return
1043
+
}
1044
+
1045
+
sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1046
+
if err != nil {
1047
+
log.Println("failed to reach knotserver for source branches", err)
1048
+
return
1049
+
}
1050
+
1051
+
sourceBody, err := io.ReadAll(sourceResp.Body)
1052
+
if err != nil {
1053
+
log.Println("failed to read source response body", err)
1054
+
return
1055
+
}
1056
+
defer sourceResp.Body.Close()
1057
+
1058
+
var sourceResult types.RepoBranchesResponse
1059
+
err = json.Unmarshal(sourceBody, &sourceResult)
1060
+
if err != nil {
1061
+
log.Println("failed to parse source branches response:", err)
1062
+
return
1063
+
}
1064
+
1065
+
targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1066
+
if err != nil {
1067
+
log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1068
+
s.pages.Error503(w)
1069
+
return
1070
+
}
1071
+
1072
+
targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1073
+
if err != nil {
1074
+
log.Println("failed to reach knotserver for target branches", err)
1075
+
return
1076
+
}
1077
+
1078
+
targetBody, err := io.ReadAll(targetResp.Body)
1079
+
if err != nil {
1080
+
log.Println("failed to read target response body", err)
1081
+
return
1082
+
}
1083
+
defer targetResp.Body.Close()
1084
+
1085
+
var targetResult types.RepoBranchesResponse
1086
+
err = json.Unmarshal(targetBody, &targetResult)
1087
+
if err != nil {
1088
+
log.Println("failed to parse target branches response:", err)
1089
+
return
1090
+
}
1091
+
1092
+
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1093
+
RepoInfo: f.RepoInfo(s, user),
1094
+
SourceBranches: sourceResult.Branches,
1095
+
TargetBranches: targetResult.Branches,
1096
+
})
1097
}
1098
1099
func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1100
+
user := s.oauth.GetUser(r)
1101
+
f, err := s.fullyResolvedRepo(r)
1102
if err != nil {
1103
log.Println("failed to get repo and knot", err)
1104
return
···
1119
})
1120
return
1121
case http.MethodPost:
1122
+
if pull.IsPatchBased() {
1123
+
s.resubmitPatch(w, r)
1124
+
return
1125
+
} else if pull.IsBranchBased() {
1126
+
s.resubmitBranch(w, r)
1127
+
return
1128
+
} else if pull.IsForkBased() {
1129
+
s.resubmitFork(w, r)
1130
return
1131
}
1132
+
}
1133
+
}
1134
1135
+
func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1136
+
user := s.oauth.GetUser(r)
1137
+
1138
+
pull, ok := r.Context().Value("pull").(*db.Pull)
1139
+
if !ok {
1140
+
log.Println("failed to get pull")
1141
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1142
+
return
1143
+
}
1144
+
1145
+
f, err := s.fullyResolvedRepo(r)
1146
+
if err != nil {
1147
+
log.Println("failed to get repo and knot", err)
1148
+
return
1149
+
}
1150
+
1151
+
if user.Did != pull.OwnerDid {
1152
+
log.Println("unauthorized user")
1153
+
w.WriteHeader(http.StatusUnauthorized)
1154
+
return
1155
+
}
1156
+
1157
+
patch := r.FormValue("patch")
1158
+
1159
+
if err = validateResubmittedPatch(pull, patch); err != nil {
1160
+
s.pages.Notice(w, "resubmit-error", err.Error())
1161
+
return
1162
+
}
1163
+
1164
+
tx, err := s.db.BeginTx(r.Context(), nil)
1165
+
if err != nil {
1166
+
log.Println("failed to start tx")
1167
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1168
+
return
1169
+
}
1170
+
defer tx.Rollback()
1171
+
1172
+
err = db.ResubmitPull(tx, pull, patch, "")
1173
+
if err != nil {
1174
+
log.Println("failed to resubmit pull request", err)
1175
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.")
1176
+
return
1177
+
}
1178
+
client, err := s.oauth.AuthorizedClient(r)
1179
+
if err != nil {
1180
+
log.Println("failed to get authorized client", err)
1181
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1182
+
return
1183
+
}
1184
+
1185
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1186
+
if err != nil {
1187
+
// failed to get record
1188
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1189
+
return
1190
+
}
1191
+
1192
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1193
+
Collection: tangled.RepoPullNSID,
1194
+
Repo: user.Did,
1195
+
Rkey: pull.Rkey,
1196
+
SwapRecord: ex.Cid,
1197
+
Record: &lexutil.LexiconTypeDecoder{
1198
+
Val: &tangled.RepoPull{
1199
+
Title: pull.Title,
1200
+
PullId: int64(pull.PullId),
1201
+
TargetRepo: string(f.RepoAt),
1202
+
TargetBranch: pull.TargetBranch,
1203
+
Patch: patch, // new patch
1204
+
},
1205
+
},
1206
+
})
1207
+
if err != nil {
1208
+
log.Println("failed to update record", err)
1209
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1210
+
return
1211
+
}
1212
+
1213
+
if err = tx.Commit(); err != nil {
1214
+
log.Println("failed to commit transaction", err)
1215
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1216
+
return
1217
+
}
1218
+
1219
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1220
+
return
1221
+
}
1222
+
1223
+
func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1224
+
user := s.oauth.GetUser(r)
1225
+
1226
+
pull, ok := r.Context().Value("pull").(*db.Pull)
1227
+
if !ok {
1228
+
log.Println("failed to get pull")
1229
+
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1230
+
return
1231
+
}
1232
+
1233
+
f, err := s.fullyResolvedRepo(r)
1234
+
if err != nil {
1235
+
log.Println("failed to get repo and knot", err)
1236
+
return
1237
+
}
1238
+
1239
+
if user.Did != pull.OwnerDid {
1240
+
log.Println("unauthorized user")
1241
+
w.WriteHeader(http.StatusUnauthorized)
1242
+
return
1243
+
}
1244
+
1245
+
if !f.RepoInfo(s, user).Roles.IsPushAllowed() {
1246
+
log.Println("unauthorized user")
1247
+
w.WriteHeader(http.StatusUnauthorized)
1248
+
return
1249
+
}
1250
+
1251
+
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1252
+
if err != nil {
1253
+
log.Printf("failed to create client for %s: %s", f.Knot, err)
1254
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1255
+
return
1256
+
}
1257
1258
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1259
+
if err != nil {
1260
+
log.Printf("compare request failed: %s", err)
1261
+
s.pages.Notice(w, "resubmit-error", err.Error())
1262
+
return
1263
+
}
1264
+
1265
+
sourceRev := comparison.Rev2
1266
+
patch := comparison.Patch
1267
+
1268
+
if err = validateResubmittedPatch(pull, patch); err != nil {
1269
+
s.pages.Notice(w, "resubmit-error", err.Error())
1270
+
return
1271
+
}
1272
+
1273
+
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1274
+
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1275
+
return
1276
+
}
1277
1278
+
tx, err := s.db.BeginTx(r.Context(), nil)
1279
+
if err != nil {
1280
+
log.Println("failed to start tx")
1281
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1282
+
return
1283
+
}
1284
+
defer tx.Rollback()
1285
1286
+
err = db.ResubmitPull(tx, pull, patch, sourceRev)
1287
+
if err != nil {
1288
+
log.Println("failed to create pull request", err)
1289
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1290
+
return
1291
+
}
1292
+
client, err := s.oauth.AuthorizedClient(r)
1293
+
if err != nil {
1294
+
log.Println("failed to authorize client")
1295
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1296
+
return
1297
+
}
1298
1299
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1300
+
if err != nil {
1301
+
// failed to get record
1302
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1303
+
return
1304
+
}
1305
1306
+
recordPullSource := &tangled.RepoPull_Source{
1307
+
Branch: pull.PullSource.Branch,
1308
+
}
1309
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1310
+
Collection: tangled.RepoPullNSID,
1311
+
Repo: user.Did,
1312
+
Rkey: pull.Rkey,
1313
+
SwapRecord: ex.Cid,
1314
+
Record: &lexutil.LexiconTypeDecoder{
1315
+
Val: &tangled.RepoPull{
1316
+
Title: pull.Title,
1317
+
PullId: int64(pull.PullId),
1318
+
TargetRepo: string(f.RepoAt),
1319
+
TargetBranch: pull.TargetBranch,
1320
+
Patch: patch, // new patch
1321
+
Source: recordPullSource,
1322
},
1323
+
},
1324
+
})
1325
+
if err != nil {
1326
+
log.Println("failed to update record", err)
1327
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1328
+
return
1329
+
}
1330
+
1331
+
if err = tx.Commit(); err != nil {
1332
+
log.Println("failed to commit transaction", err)
1333
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1334
+
return
1335
+
}
1336
+
1337
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1338
+
return
1339
+
}
1340
+
1341
+
func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
1342
+
user := s.oauth.GetUser(r)
1343
+
1344
+
pull, ok := r.Context().Value("pull").(*db.Pull)
1345
+
if !ok {
1346
+
log.Println("failed to get pull")
1347
+
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1348
+
return
1349
+
}
1350
+
1351
+
f, err := s.fullyResolvedRepo(r)
1352
+
if err != nil {
1353
+
log.Println("failed to get repo and knot", err)
1354
+
return
1355
+
}
1356
+
1357
+
if user.Did != pull.OwnerDid {
1358
+
log.Println("unauthorized user")
1359
+
w.WriteHeader(http.StatusUnauthorized)
1360
+
return
1361
+
}
1362
+
1363
+
forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1364
+
if err != nil {
1365
+
log.Println("failed to get source repo", err)
1366
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1367
+
return
1368
+
}
1369
1370
+
// extract patch by performing compare
1371
+
ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev)
1372
+
if err != nil {
1373
+
log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1374
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1375
+
return
1376
+
}
1377
+
1378
+
secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1379
+
if err != nil {
1380
+
log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1381
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1382
+
return
1383
+
}
1384
1385
+
// update the hidden tracking branch to latest
1386
+
signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev)
1387
+
if err != nil {
1388
+
log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1389
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1390
return
1391
}
1392
+
1393
+
resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1394
+
if err != nil || resp.StatusCode != http.StatusNoContent {
1395
+
log.Printf("failed to update tracking branch: %s", err)
1396
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1397
+
return
1398
+
}
1399
+
1400
+
hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1401
+
comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1402
+
if err != nil {
1403
+
log.Printf("failed to compare branches: %s", err)
1404
+
s.pages.Notice(w, "resubmit-error", err.Error())
1405
+
return
1406
+
}
1407
+
1408
+
sourceRev := comparison.Rev2
1409
+
patch := comparison.Patch
1410
+
1411
+
if err = validateResubmittedPatch(pull, patch); err != nil {
1412
+
s.pages.Notice(w, "resubmit-error", err.Error())
1413
+
return
1414
+
}
1415
+
1416
+
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1417
+
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1418
+
return
1419
+
}
1420
+
1421
+
tx, err := s.db.BeginTx(r.Context(), nil)
1422
+
if err != nil {
1423
+
log.Println("failed to start tx")
1424
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1425
+
return
1426
+
}
1427
+
defer tx.Rollback()
1428
+
1429
+
err = db.ResubmitPull(tx, pull, patch, sourceRev)
1430
+
if err != nil {
1431
+
log.Println("failed to create pull request", err)
1432
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1433
+
return
1434
+
}
1435
+
client, err := s.oauth.AuthorizedClient(r)
1436
+
if err != nil {
1437
+
log.Println("failed to get client")
1438
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1439
+
return
1440
+
}
1441
+
1442
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1443
+
if err != nil {
1444
+
// failed to get record
1445
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1446
+
return
1447
+
}
1448
+
1449
+
repoAt := pull.PullSource.RepoAt.String()
1450
+
recordPullSource := &tangled.RepoPull_Source{
1451
+
Branch: pull.PullSource.Branch,
1452
+
Repo: &repoAt,
1453
+
}
1454
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1455
+
Collection: tangled.RepoPullNSID,
1456
+
Repo: user.Did,
1457
+
Rkey: pull.Rkey,
1458
+
SwapRecord: ex.Cid,
1459
+
Record: &lexutil.LexiconTypeDecoder{
1460
+
Val: &tangled.RepoPull{
1461
+
Title: pull.Title,
1462
+
PullId: int64(pull.PullId),
1463
+
TargetRepo: string(f.RepoAt),
1464
+
TargetBranch: pull.TargetBranch,
1465
+
Patch: patch, // new patch
1466
+
Source: recordPullSource,
1467
+
},
1468
+
},
1469
+
})
1470
+
if err != nil {
1471
+
log.Println("failed to update record", err)
1472
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1473
+
return
1474
+
}
1475
+
1476
+
if err = tx.Commit(); err != nil {
1477
+
log.Println("failed to commit transaction", err)
1478
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1479
+
return
1480
+
}
1481
+
1482
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1483
+
return
1484
+
}
1485
+
1486
+
// validate a resubmission against a pull request
1487
+
func validateResubmittedPatch(pull *db.Pull, patch string) error {
1488
+
if patch == "" {
1489
+
return fmt.Errorf("Patch is empty.")
1490
+
}
1491
+
1492
+
if patch == pull.LatestPatch() {
1493
+
return fmt.Errorf("Patch is identical to previous submission.")
1494
+
}
1495
+
1496
+
if !patchutil.IsPatchValid(patch) {
1497
+
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1498
+
}
1499
+
1500
+
return nil
1501
}
1502
1503
func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1504
+
f, err := s.fullyResolvedRepo(r)
1505
if err != nil {
1506
log.Println("failed to resolve repo:", err)
1507
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
1522
return
1523
}
1524
1525
+
ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
1526
+
if err != nil {
1527
+
log.Printf("resolving identity: %s", err)
1528
+
w.WriteHeader(http.StatusNotFound)
1529
+
return
1530
+
}
1531
+
1532
+
email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1533
+
if err != nil {
1534
+
log.Printf("failed to get primary email: %s", err)
1535
+
}
1536
+
1537
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
1538
if err != nil {
1539
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1540
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
1542
}
1543
1544
// Merge the pull request
1545
+
resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1546
if err != nil {
1547
log.Printf("failed to merge pull request: %s", err)
1548
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
1564
}
1565
1566
func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1567
+
user := s.oauth.GetUser(r)
1568
1569
+
f, err := s.fullyResolvedRepo(r)
1570
if err != nil {
1571
log.Println("malformed middleware")
1572
return
···
1618
}
1619
1620
func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1621
+
user := s.oauth.GetUser(r)
1622
1623
+
f, err := s.fullyResolvedRepo(r)
1624
if err != nil {
1625
log.Println("failed to resolve repo", err)
1626
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
···
1671
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1672
return
1673
}
+1006
-99
appview/state/repo.go
+1006
-99
appview/state/repo.go
···
2
3
import (
4
"context"
5
"encoding/json"
6
"fmt"
7
"io"
8
"log"
9
-
"math/rand/v2"
10
"net/http"
11
"path"
12
"slices"
···
14
"strings"
15
"time"
16
17
"github.com/bluesky-social/indigo/atproto/identity"
18
"github.com/bluesky-social/indigo/atproto/syntax"
19
securejoin "github.com/cyphar/filepath-securejoin"
20
"github.com/go-chi/chi/v5"
21
-
"tangled.sh/tangled.sh/core/api/tangled"
22
-
"tangled.sh/tangled.sh/core/appview/auth"
23
-
"tangled.sh/tangled.sh/core/appview/db"
24
-
"tangled.sh/tangled.sh/core/appview/pages"
25
-
"tangled.sh/tangled.sh/core/types"
26
27
comatproto "github.com/bluesky-social/indigo/api/atproto"
28
lexutil "github.com/bluesky-social/indigo/lex/util"
···
30
31
func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) {
32
ref := chi.URLParam(r, "ref")
33
-
f, err := fullyResolvedRepo(r)
34
if err != nil {
35
log.Println("failed to fully resolve repo", err)
36
return
37
}
38
39
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
40
if err != nil {
41
log.Printf("failed to create unsigned client for %s", f.Knot)
42
s.pages.Error503(w)
···
67
tagMap := make(map[string][]string)
68
for _, tag := range result.Tags {
69
hash := tag.Hash
70
tagMap[hash] = append(tagMap[hash], tag.Name)
71
}
72
···
75
tagMap[hash] = append(tagMap[hash], branch.Name)
76
}
77
78
-
emails := uniqueEmails(result.Commits)
79
80
-
user := s.auth.GetUser(r)
81
s.pages.RepoIndexPage(w, pages.RepoIndexParams{
82
LoggedInUser: user,
83
RepoInfo: f.RepoInfo(s, user),
84
TagMap: tagMap,
85
RepoIndexResponse: result,
86
EmailToDidOrHandle: EmailToDidOrHandle(s, emails),
87
})
88
return
89
}
90
91
func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {
92
-
f, err := fullyResolvedRepo(r)
93
if err != nil {
94
log.Println("failed to fully resolve repo", err)
95
return
···
105
106
ref := chi.URLParam(r, "ref")
107
108
-
protocol := "http"
109
-
if !s.config.Dev {
110
-
protocol = "https"
111
}
112
113
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/log/%s?page=%d&per_page=30", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, page))
114
if err != nil {
115
log.Println("failed to reach knotserver", err)
116
return
···
129
return
130
}
131
132
-
user := s.auth.GetUser(r)
133
s.pages.RepoLog(w, pages.RepoLogParams{
134
LoggedInUser: user,
135
RepoInfo: f.RepoInfo(s, user),
136
RepoLogResponse: repolog,
137
EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)),
···
140
}
141
142
func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
143
-
f, err := fullyResolvedRepo(r)
144
if err != nil {
145
log.Println("failed to get repo and knot", err)
146
w.WriteHeader(http.StatusBadRequest)
147
return
148
}
149
150
-
user := s.auth.GetUser(r)
151
s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
152
RepoInfo: f.RepoInfo(s, user),
153
})
···
155
}
156
157
func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) {
158
-
f, err := fullyResolvedRepo(r)
159
if err != nil {
160
log.Println("failed to get repo and knot", err)
161
w.WriteHeader(http.StatusBadRequest)
···
170
return
171
}
172
173
-
user := s.auth.GetUser(r)
174
175
switch r.Method {
176
case http.MethodGet:
···
179
})
180
return
181
case http.MethodPut:
182
-
user := s.auth.GetUser(r)
183
newDescription := r.FormValue("description")
184
-
client, _ := s.auth.AuthorizedClient(r)
185
186
// optimistic update
187
err = db.UpdateDescription(s.db, string(repoAt), newDescription)
···
194
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
195
//
196
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
197
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, user.Did, rkey)
198
if err != nil {
199
// failed to get record
200
s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
201
return
202
}
203
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
204
Collection: tangled.RepoNSID,
205
Repo: user.Did,
206
Rkey: rkey,
···
210
Knot: f.Knot,
211
Name: f.RepoName,
212
Owner: user.Did,
213
-
AddedAt: &f.AddedAt,
214
Description: &newDescription,
215
},
216
},
···
234
}
235
236
func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) {
237
-
f, err := fullyResolvedRepo(r)
238
if err != nil {
239
log.Println("failed to fully resolve repo", err)
240
return
241
}
242
ref := chi.URLParam(r, "ref")
243
protocol := "http"
244
-
if !s.config.Dev {
245
protocol = "https"
246
}
247
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
248
if err != nil {
249
log.Println("failed to reach knotserver", err)
···
263
return
264
}
265
266
-
user := s.auth.GetUser(r)
267
s.pages.RepoCommit(w, pages.RepoCommitParams{
268
LoggedInUser: user,
269
RepoInfo: f.RepoInfo(s, user),
···
274
}
275
276
func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) {
277
-
f, err := fullyResolvedRepo(r)
278
if err != nil {
279
log.Println("failed to fully resolve repo", err)
280
return
···
283
ref := chi.URLParam(r, "ref")
284
treePath := chi.URLParam(r, "*")
285
protocol := "http"
286
-
if !s.config.Dev {
287
protocol = "https"
288
}
289
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
···
305
return
306
}
307
308
-
user := s.auth.GetUser(r)
309
310
var breadcrumbs [][]string
311
-
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
312
if treePath != "" {
313
for idx, elem := range strings.Split(treePath, "/") {
314
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
315
}
316
}
317
318
-
baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath)
319
-
baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath)
320
321
s.pages.RepoTree(w, pages.RepoTreeParams{
322
LoggedInUser: user,
···
330
}
331
332
func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) {
333
-
f, err := fullyResolvedRepo(r)
334
if err != nil {
335
log.Println("failed to get repo and knot", err)
336
return
337
}
338
339
-
protocol := "http"
340
-
if !s.config.Dev {
341
-
protocol = "https"
342
}
343
344
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tags", protocol, f.Knot, f.OwnerDid(), f.RepoName))
345
if err != nil {
346
log.Println("failed to reach knotserver", err)
347
return
348
}
349
350
-
body, err := io.ReadAll(resp.Body)
351
if err != nil {
352
-
log.Printf("Error reading response body: %v", err)
353
return
354
}
355
356
-
var result types.RepoTagsResponse
357
-
err = json.Unmarshal(body, &result)
358
-
if err != nil {
359
-
log.Println("failed to parse response:", err)
360
-
return
361
}
362
363
-
user := s.auth.GetUser(r)
364
s.pages.RepoTags(w, pages.RepoTagsParams{
365
-
LoggedInUser: user,
366
-
RepoInfo: f.RepoInfo(s, user),
367
-
RepoTagsResponse: result,
368
})
369
return
370
}
371
372
func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) {
373
-
f, err := fullyResolvedRepo(r)
374
if err != nil {
375
log.Println("failed to get repo and knot", err)
376
return
377
}
378
379
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
380
if err != nil {
381
log.Println("failed to create unsigned client", err)
382
return
···
401
return
402
}
403
404
-
user := s.auth.GetUser(r)
405
s.pages.RepoBranches(w, pages.RepoBranchesParams{
406
LoggedInUser: user,
407
RepoInfo: f.RepoInfo(s, user),
···
411
}
412
413
func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) {
414
-
f, err := fullyResolvedRepo(r)
415
if err != nil {
416
log.Println("failed to get repo and knot", err)
417
return
···
420
ref := chi.URLParam(r, "ref")
421
filePath := chi.URLParam(r, "*")
422
protocol := "http"
423
-
if !s.config.Dev {
424
protocol = "https"
425
}
426
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
···
443
}
444
445
var breadcrumbs [][]string
446
-
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
447
if filePath != "" {
448
for idx, elem := range strings.Split(filePath, "/") {
449
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
450
}
451
}
452
453
-
user := s.auth.GetUser(r)
454
s.pages.RepoBlob(w, pages.RepoBlobParams{
455
LoggedInUser: user,
456
RepoInfo: f.RepoInfo(s, user),
457
RepoBlobResponse: result,
458
BreadCrumbs: breadcrumbs,
459
})
460
return
461
}
462
463
func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
464
-
f, err := fullyResolvedRepo(r)
465
if err != nil {
466
log.Println("failed to get repo and knot", err)
467
return
···
488
return
489
}
490
491
-
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
492
if err != nil {
493
log.Println("failed to create client to ", f.Knot)
494
return
···
519
}
520
}()
521
522
-
err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo())
523
if err != nil {
524
w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
525
return
···
549
550
}
551
552
func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
553
-
f, err := fullyResolvedRepo(r)
554
if err != nil {
555
log.Println("failed to get repo and knot", err)
556
return
···
559
switch r.Method {
560
case http.MethodGet:
561
// for now, this is just pubkeys
562
-
user := s.auth.GetUser(r)
563
repoCollaborators, err := f.Collaborators(r.Context(), s)
564
if err != nil {
565
log.Println("failed to get collaborators", err)
···
567
568
isCollaboratorInviteAllowed := false
569
if user != nil {
570
-
ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo())
571
if err == nil && ok {
572
isCollaboratorInviteAllowed = true
573
}
574
}
575
576
s.pages.RepoSettings(w, pages.RepoSettingsParams{
577
LoggedInUser: user,
578
RepoInfo: f.RepoInfo(s, user),
579
Collaborators: repoCollaborators,
580
IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
581
})
582
}
583
}
···
588
RepoName string
589
RepoAt syntax.ATURI
590
Description string
591
-
AddedAt string
592
}
593
594
func (f *FullyResolvedRepo) OwnerDid() string {
···
600
}
601
602
func (f *FullyResolvedRepo) OwnerSlashRepo() string {
603
p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
604
return p
605
}
606
607
func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
608
-
repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot)
609
if err != nil {
610
return nil, err
611
}
···
648
return collaborators, nil
649
}
650
651
-
func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo {
652
isStarred := false
653
if u != nil {
654
isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
···
666
if err != nil {
667
log.Println("failed to get issue count for ", f.RepoAt)
668
}
669
670
knot := f.Knot
671
-
if knot == "knot1.tangled.sh" {
672
-
knot = "tangled.sh"
673
}
674
675
-
return pages.RepoInfo{
676
OwnerDid: f.OwnerDid(),
677
OwnerHandle: f.OwnerHandle(),
678
Name: f.RepoName,
679
RepoAt: f.RepoAt,
680
Description: f.Description,
681
IsStarred: isStarred,
682
Knot: knot,
683
Roles: RolesInRepo(s, u, f),
···
686
IssueCount: issueCount,
687
PullCount: pullCount,
688
},
689
}
690
}
691
692
func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
693
-
user := s.auth.GetUser(r)
694
-
f, err := fullyResolvedRepo(r)
695
if err != nil {
696
log.Println("failed to get repo and knot", err)
697
return
···
744
}
745
746
func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
747
-
user := s.auth.GetUser(r)
748
-
f, err := fullyResolvedRepo(r)
749
if err != nil {
750
log.Println("failed to get repo and knot", err)
751
return
···
780
781
closed := tangled.RepoIssueStateClosed
782
783
-
client, _ := s.auth.AuthorizedClient(r)
784
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
785
Collection: tangled.RepoIssueStateNSID,
786
Repo: user.Did,
787
-
Rkey: s.TID(),
788
Record: &lexutil.LexiconTypeDecoder{
789
Val: &tangled.RepoIssueState{
790
Issue: issue.IssueAt,
791
-
State: &closed,
792
},
793
},
794
})
···
799
return
800
}
801
802
-
err := db.CloseIssue(s.db, f.RepoAt, issueIdInt)
803
if err != nil {
804
log.Println("failed to close issue", err)
805
s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
···
816
}
817
818
func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
819
-
user := s.auth.GetUser(r)
820
-
f, err := fullyResolvedRepo(r)
821
if err != nil {
822
log.Println("failed to get repo and knot", err)
823
return
···
863
}
864
}
865
866
-
func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
867
-
user := s.auth.GetUser(r)
868
-
f, err := fullyResolvedRepo(r)
869
if err != nil {
870
log.Println("failed to get repo and knot", err)
871
return
···
887
return
888
}
889
890
-
commentId := rand.IntN(1000000)
891
892
-
err := db.NewComment(s.db, &db.Comment{
893
OwnerDid: user.Did,
894
RepoAt: f.RepoAt,
895
Issue: issueIdInt,
896
CommentId: commentId,
897
Body: body,
898
})
899
if err != nil {
900
log.Println("failed to create comment", err)
···
913
}
914
915
atUri := f.RepoAt.String()
916
-
client, _ := s.auth.AuthorizedClient(r)
917
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
918
Collection: tangled.RepoIssueCommentNSID,
919
Repo: user.Did,
920
-
Rkey: s.TID(),
921
Record: &lexutil.LexiconTypeDecoder{
922
Val: &tangled.RepoIssueComment{
923
Repo: &atUri,
924
Issue: issueAt,
925
CommentId: &commentIdInt64,
926
Owner: &ownerDid,
927
-
Body: &body,
928
-
CreatedAt: &createdAt,
929
},
930
},
931
})
···
940
}
941
}
942
943
func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
944
params := r.URL.Query()
945
state := params.Get("state")
···
953
isOpen = true
954
}
955
956
-
user := s.auth.GetUser(r)
957
-
f, err := fullyResolvedRepo(r)
958
if err != nil {
959
log.Println("failed to get repo and knot", err)
960
return
961
}
962
963
-
issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
964
if err != nil {
965
log.Println("failed to get issues", err)
966
s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
982
}
983
984
s.pages.RepoIssues(w, pages.RepoIssuesParams{
985
-
LoggedInUser: s.auth.GetUser(r),
986
RepoInfo: f.RepoInfo(s, user),
987
Issues: issues,
988
DidHandleMap: didHandleMap,
989
FilteringByOpen: isOpen,
990
})
991
return
992
}
993
994
func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
995
-
user := s.auth.GetUser(r)
996
997
-
f, err := fullyResolvedRepo(r)
998
if err != nil {
999
log.Println("failed to get repo and knot", err)
1000
return
···
1040
return
1041
}
1042
1043
-
client, _ := s.auth.AuthorizedClient(r)
1044
atUri := f.RepoAt.String()
1045
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1046
Collection: tangled.RepoIssueNSID,
1047
Repo: user.Did,
1048
-
Rkey: s.TID(),
1049
Record: &lexutil.LexiconTypeDecoder{
1050
Val: &tangled.RepoIssue{
1051
Repo: atUri,
···
1073
return
1074
}
1075
}
···
2
3
import (
4
"context"
5
+
"database/sql"
6
"encoding/json"
7
+
"errors"
8
"fmt"
9
"io"
10
"log"
11
+
mathrand "math/rand/v2"
12
"net/http"
13
"path"
14
"slices"
···
16
"strings"
17
"time"
18
19
+
"tangled.sh/tangled.sh/core/api/tangled"
20
+
"tangled.sh/tangled.sh/core/appview"
21
+
"tangled.sh/tangled.sh/core/appview/db"
22
+
"tangled.sh/tangled.sh/core/appview/knotclient"
23
+
"tangled.sh/tangled.sh/core/appview/oauth"
24
+
"tangled.sh/tangled.sh/core/appview/pages"
25
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
26
+
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
27
+
"tangled.sh/tangled.sh/core/appview/pagination"
28
+
"tangled.sh/tangled.sh/core/types"
29
+
30
+
"github.com/bluesky-social/indigo/atproto/data"
31
"github.com/bluesky-social/indigo/atproto/identity"
32
"github.com/bluesky-social/indigo/atproto/syntax"
33
securejoin "github.com/cyphar/filepath-securejoin"
34
"github.com/go-chi/chi/v5"
35
+
"github.com/go-git/go-git/v5/plumbing"
36
37
comatproto "github.com/bluesky-social/indigo/api/atproto"
38
lexutil "github.com/bluesky-social/indigo/lex/util"
···
40
41
func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) {
42
ref := chi.URLParam(r, "ref")
43
+
f, err := s.fullyResolvedRepo(r)
44
if err != nil {
45
log.Println("failed to fully resolve repo", err)
46
return
47
}
48
49
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
50
if err != nil {
51
log.Printf("failed to create unsigned client for %s", f.Knot)
52
s.pages.Error503(w)
···
77
tagMap := make(map[string][]string)
78
for _, tag := range result.Tags {
79
hash := tag.Hash
80
+
if tag.Tag != nil {
81
+
hash = tag.Tag.Target.String()
82
+
}
83
tagMap[hash] = append(tagMap[hash], tag.Name)
84
}
85
···
88
tagMap[hash] = append(tagMap[hash], branch.Name)
89
}
90
91
+
slices.SortFunc(result.Branches, func(a, b types.Branch) int {
92
+
if a.Name == result.Ref {
93
+
return -1
94
+
}
95
+
if a.IsDefault {
96
+
return -1
97
+
}
98
+
if b.IsDefault {
99
+
return 1
100
+
}
101
+
if a.Commit != nil {
102
+
if a.Commit.Author.When.Before(b.Commit.Author.When) {
103
+
return 1
104
+
} else {
105
+
return -1
106
+
}
107
+
}
108
+
return strings.Compare(a.Name, b.Name) * -1
109
+
})
110
111
+
commitCount := len(result.Commits)
112
+
branchCount := len(result.Branches)
113
+
tagCount := len(result.Tags)
114
+
fileCount := len(result.Files)
115
+
116
+
commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount)
117
+
commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))]
118
+
tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))]
119
+
branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))]
120
+
121
+
emails := uniqueEmails(commitsTrunc)
122
+
123
+
user := s.oauth.GetUser(r)
124
s.pages.RepoIndexPage(w, pages.RepoIndexParams{
125
LoggedInUser: user,
126
RepoInfo: f.RepoInfo(s, user),
127
TagMap: tagMap,
128
RepoIndexResponse: result,
129
+
CommitsTrunc: commitsTrunc,
130
+
TagsTrunc: tagsTrunc,
131
+
BranchesTrunc: branchesTrunc,
132
EmailToDidOrHandle: EmailToDidOrHandle(s, emails),
133
})
134
return
135
}
136
137
func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {
138
+
f, err := s.fullyResolvedRepo(r)
139
if err != nil {
140
log.Println("failed to fully resolve repo", err)
141
return
···
151
152
ref := chi.URLParam(r, "ref")
153
154
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
155
+
if err != nil {
156
+
log.Println("failed to create unsigned client", err)
157
+
return
158
}
159
160
+
resp, err := us.Log(f.OwnerDid(), f.RepoName, ref, page)
161
if err != nil {
162
log.Println("failed to reach knotserver", err)
163
return
···
176
return
177
}
178
179
+
result, err := us.Tags(f.OwnerDid(), f.RepoName)
180
+
if err != nil {
181
+
log.Println("failed to reach knotserver", err)
182
+
return
183
+
}
184
+
185
+
tagMap := make(map[string][]string)
186
+
for _, tag := range result.Tags {
187
+
hash := tag.Hash
188
+
if tag.Tag != nil {
189
+
hash = tag.Tag.Target.String()
190
+
}
191
+
tagMap[hash] = append(tagMap[hash], tag.Name)
192
+
}
193
+
194
+
user := s.oauth.GetUser(r)
195
s.pages.RepoLog(w, pages.RepoLogParams{
196
LoggedInUser: user,
197
+
TagMap: tagMap,
198
RepoInfo: f.RepoInfo(s, user),
199
RepoLogResponse: repolog,
200
EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)),
···
203
}
204
205
func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
206
+
f, err := s.fullyResolvedRepo(r)
207
if err != nil {
208
log.Println("failed to get repo and knot", err)
209
w.WriteHeader(http.StatusBadRequest)
210
return
211
}
212
213
+
user := s.oauth.GetUser(r)
214
s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
215
RepoInfo: f.RepoInfo(s, user),
216
})
···
218
}
219
220
func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) {
221
+
f, err := s.fullyResolvedRepo(r)
222
if err != nil {
223
log.Println("failed to get repo and knot", err)
224
w.WriteHeader(http.StatusBadRequest)
···
233
return
234
}
235
236
+
user := s.oauth.GetUser(r)
237
238
switch r.Method {
239
case http.MethodGet:
···
242
})
243
return
244
case http.MethodPut:
245
+
user := s.oauth.GetUser(r)
246
newDescription := r.FormValue("description")
247
+
client, err := s.oauth.AuthorizedClient(r)
248
+
if err != nil {
249
+
log.Println("failed to get client")
250
+
s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
251
+
return
252
+
}
253
254
// optimistic update
255
err = db.UpdateDescription(s.db, string(repoAt), newDescription)
···
262
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
263
//
264
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
265
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
266
if err != nil {
267
// failed to get record
268
s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
269
return
270
}
271
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
272
Collection: tangled.RepoNSID,
273
Repo: user.Did,
274
Rkey: rkey,
···
278
Knot: f.Knot,
279
Name: f.RepoName,
280
Owner: user.Did,
281
+
CreatedAt: f.CreatedAt,
282
Description: &newDescription,
283
},
284
},
···
302
}
303
304
func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) {
305
+
f, err := s.fullyResolvedRepo(r)
306
if err != nil {
307
log.Println("failed to fully resolve repo", err)
308
return
309
}
310
ref := chi.URLParam(r, "ref")
311
protocol := "http"
312
+
if !s.config.Core.Dev {
313
protocol = "https"
314
}
315
+
316
+
if !plumbing.IsHash(ref) {
317
+
s.pages.Error404(w)
318
+
return
319
+
}
320
+
321
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
322
if err != nil {
323
log.Println("failed to reach knotserver", err)
···
337
return
338
}
339
340
+
user := s.oauth.GetUser(r)
341
s.pages.RepoCommit(w, pages.RepoCommitParams{
342
LoggedInUser: user,
343
RepoInfo: f.RepoInfo(s, user),
···
348
}
349
350
func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) {
351
+
f, err := s.fullyResolvedRepo(r)
352
if err != nil {
353
log.Println("failed to fully resolve repo", err)
354
return
···
357
ref := chi.URLParam(r, "ref")
358
treePath := chi.URLParam(r, "*")
359
protocol := "http"
360
+
if !s.config.Core.Dev {
361
protocol = "https"
362
}
363
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
···
379
return
380
}
381
382
+
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
383
+
// so we can safely redirect to the "parent" (which is the same file).
384
+
if len(result.Files) == 0 && result.Parent == treePath {
385
+
http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
386
+
return
387
+
}
388
+
389
+
user := s.oauth.GetUser(r)
390
391
var breadcrumbs [][]string
392
+
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
393
if treePath != "" {
394
for idx, elem := range strings.Split(treePath, "/") {
395
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
396
}
397
}
398
399
+
baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
400
+
baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
401
402
s.pages.RepoTree(w, pages.RepoTreeParams{
403
LoggedInUser: user,
···
411
}
412
413
func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) {
414
+
f, err := s.fullyResolvedRepo(r)
415
if err != nil {
416
log.Println("failed to get repo and knot", err)
417
return
418
}
419
420
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
421
+
if err != nil {
422
+
log.Println("failed to create unsigned client", err)
423
+
return
424
}
425
426
+
result, err := us.Tags(f.OwnerDid(), f.RepoName)
427
if err != nil {
428
log.Println("failed to reach knotserver", err)
429
return
430
}
431
432
+
artifacts, err := db.GetArtifact(s.db, db.Filter("repo_at", f.RepoAt))
433
if err != nil {
434
+
log.Println("failed grab artifacts", err)
435
return
436
}
437
438
+
// convert artifacts to map for easy UI building
439
+
artifactMap := make(map[plumbing.Hash][]db.Artifact)
440
+
for _, a := range artifacts {
441
+
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
442
+
}
443
+
444
+
var danglingArtifacts []db.Artifact
445
+
for _, a := range artifacts {
446
+
found := false
447
+
for _, t := range result.Tags {
448
+
if t.Tag != nil {
449
+
if t.Tag.Hash == a.Tag {
450
+
found = true
451
+
}
452
+
}
453
+
}
454
+
455
+
if !found {
456
+
danglingArtifacts = append(danglingArtifacts, a)
457
+
}
458
}
459
460
+
user := s.oauth.GetUser(r)
461
s.pages.RepoTags(w, pages.RepoTagsParams{
462
+
LoggedInUser: user,
463
+
RepoInfo: f.RepoInfo(s, user),
464
+
RepoTagsResponse: *result,
465
+
ArtifactMap: artifactMap,
466
+
DanglingArtifacts: danglingArtifacts,
467
})
468
return
469
}
470
471
func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) {
472
+
f, err := s.fullyResolvedRepo(r)
473
if err != nil {
474
log.Println("failed to get repo and knot", err)
475
return
476
}
477
478
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
479
if err != nil {
480
log.Println("failed to create unsigned client", err)
481
return
···
500
return
501
}
502
503
+
slices.SortFunc(result.Branches, func(a, b types.Branch) int {
504
+
if a.IsDefault {
505
+
return -1
506
+
}
507
+
if b.IsDefault {
508
+
return 1
509
+
}
510
+
if a.Commit != nil {
511
+
if a.Commit.Author.When.Before(b.Commit.Author.When) {
512
+
return 1
513
+
} else {
514
+
return -1
515
+
}
516
+
}
517
+
return strings.Compare(a.Name, b.Name) * -1
518
+
})
519
+
520
+
user := s.oauth.GetUser(r)
521
s.pages.RepoBranches(w, pages.RepoBranchesParams{
522
LoggedInUser: user,
523
RepoInfo: f.RepoInfo(s, user),
···
527
}
528
529
func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) {
530
+
f, err := s.fullyResolvedRepo(r)
531
if err != nil {
532
log.Println("failed to get repo and knot", err)
533
return
···
536
ref := chi.URLParam(r, "ref")
537
filePath := chi.URLParam(r, "*")
538
protocol := "http"
539
+
if !s.config.Core.Dev {
540
protocol = "https"
541
}
542
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
···
559
}
560
561
var breadcrumbs [][]string
562
+
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
563
if filePath != "" {
564
for idx, elem := range strings.Split(filePath, "/") {
565
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
566
}
567
}
568
569
+
showRendered := false
570
+
renderToggle := false
571
+
572
+
if markup.GetFormat(result.Path) == markup.FormatMarkdown {
573
+
renderToggle = true
574
+
showRendered = r.URL.Query().Get("code") != "true"
575
+
}
576
+
577
+
user := s.oauth.GetUser(r)
578
s.pages.RepoBlob(w, pages.RepoBlobParams{
579
LoggedInUser: user,
580
RepoInfo: f.RepoInfo(s, user),
581
RepoBlobResponse: result,
582
BreadCrumbs: breadcrumbs,
583
+
ShowRendered: showRendered,
584
+
RenderToggle: renderToggle,
585
})
586
return
587
}
588
589
+
func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
590
+
f, err := s.fullyResolvedRepo(r)
591
+
if err != nil {
592
+
log.Println("failed to get repo and knot", err)
593
+
return
594
+
}
595
+
596
+
ref := chi.URLParam(r, "ref")
597
+
filePath := chi.URLParam(r, "*")
598
+
599
+
protocol := "http"
600
+
if !s.config.Core.Dev {
601
+
protocol = "https"
602
+
}
603
+
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
604
+
if err != nil {
605
+
log.Println("failed to reach knotserver", err)
606
+
return
607
+
}
608
+
609
+
body, err := io.ReadAll(resp.Body)
610
+
if err != nil {
611
+
log.Printf("Error reading response body: %v", err)
612
+
return
613
+
}
614
+
615
+
var result types.RepoBlobResponse
616
+
err = json.Unmarshal(body, &result)
617
+
if err != nil {
618
+
log.Println("failed to parse response:", err)
619
+
return
620
+
}
621
+
622
+
if result.IsBinary {
623
+
w.Header().Set("Content-Type", "application/octet-stream")
624
+
w.Write(body)
625
+
return
626
+
}
627
+
628
+
w.Header().Set("Content-Type", "text/plain")
629
+
w.Write([]byte(result.Contents))
630
+
return
631
+
}
632
+
633
func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
634
+
f, err := s.fullyResolvedRepo(r)
635
if err != nil {
636
log.Println("failed to get repo and knot", err)
637
return
···
658
return
659
}
660
661
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
662
if err != nil {
663
log.Println("failed to create client to ", f.Knot)
664
return
···
689
}
690
}()
691
692
+
err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
693
if err != nil {
694
w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
695
return
···
719
720
}
721
722
+
func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) {
723
+
user := s.oauth.GetUser(r)
724
+
725
+
f, err := s.fullyResolvedRepo(r)
726
+
if err != nil {
727
+
log.Println("failed to get repo and knot", err)
728
+
return
729
+
}
730
+
731
+
// remove record from pds
732
+
xrpcClient, err := s.oauth.AuthorizedClient(r)
733
+
if err != nil {
734
+
log.Println("failed to get authorized client", err)
735
+
return
736
+
}
737
+
repoRkey := f.RepoAt.RecordKey().String()
738
+
_, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
739
+
Collection: tangled.RepoNSID,
740
+
Repo: user.Did,
741
+
Rkey: repoRkey,
742
+
})
743
+
if err != nil {
744
+
log.Printf("failed to delete record: %s", err)
745
+
s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
746
+
return
747
+
}
748
+
log.Println("removed repo record ", f.RepoAt.String())
749
+
750
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
751
+
if err != nil {
752
+
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
753
+
return
754
+
}
755
+
756
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
757
+
if err != nil {
758
+
log.Println("failed to create client to ", f.Knot)
759
+
return
760
+
}
761
+
762
+
ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
763
+
if err != nil {
764
+
log.Printf("failed to make request to %s: %s", f.Knot, err)
765
+
return
766
+
}
767
+
768
+
if ksResp.StatusCode != http.StatusNoContent {
769
+
log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
770
+
} else {
771
+
log.Println("removed repo from knot ", f.Knot)
772
+
}
773
+
774
+
tx, err := s.db.BeginTx(r.Context(), nil)
775
+
if err != nil {
776
+
log.Println("failed to start tx")
777
+
w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
778
+
return
779
+
}
780
+
defer func() {
781
+
tx.Rollback()
782
+
err = s.enforcer.E.LoadPolicy()
783
+
if err != nil {
784
+
log.Println("failed to rollback policies")
785
+
}
786
+
}()
787
+
788
+
// remove collaborator RBAC
789
+
repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
790
+
if err != nil {
791
+
s.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
792
+
return
793
+
}
794
+
for _, c := range repoCollaborators {
795
+
did := c[0]
796
+
s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
797
+
}
798
+
log.Println("removed collaborators")
799
+
800
+
// remove repo RBAC
801
+
err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
802
+
if err != nil {
803
+
s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
804
+
return
805
+
}
806
+
807
+
// remove repo from db
808
+
err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName)
809
+
if err != nil {
810
+
s.pages.Notice(w, "settings-delete", "Failed to update appview")
811
+
return
812
+
}
813
+
log.Println("removed repo from db")
814
+
815
+
err = tx.Commit()
816
+
if err != nil {
817
+
log.Println("failed to commit changes", err)
818
+
http.Error(w, err.Error(), http.StatusInternalServerError)
819
+
return
820
+
}
821
+
822
+
err = s.enforcer.E.SavePolicy()
823
+
if err != nil {
824
+
log.Println("failed to update ACLs", err)
825
+
http.Error(w, err.Error(), http.StatusInternalServerError)
826
+
return
827
+
}
828
+
829
+
s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
830
+
}
831
+
832
+
func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
833
+
f, err := s.fullyResolvedRepo(r)
834
+
if err != nil {
835
+
log.Println("failed to get repo and knot", err)
836
+
return
837
+
}
838
+
839
+
branch := r.FormValue("branch")
840
+
if branch == "" {
841
+
http.Error(w, "malformed form", http.StatusBadRequest)
842
+
return
843
+
}
844
+
845
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
846
+
if err != nil {
847
+
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
848
+
return
849
+
}
850
+
851
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
852
+
if err != nil {
853
+
log.Println("failed to create client to ", f.Knot)
854
+
return
855
+
}
856
+
857
+
ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
858
+
if err != nil {
859
+
log.Printf("failed to make request to %s: %s", f.Knot, err)
860
+
return
861
+
}
862
+
863
+
if ksResp.StatusCode != http.StatusNoContent {
864
+
s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
865
+
return
866
+
}
867
+
868
+
w.Write([]byte(fmt.Sprint("default branch set to: ", branch)))
869
+
}
870
+
871
func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
872
+
f, err := s.fullyResolvedRepo(r)
873
if err != nil {
874
log.Println("failed to get repo and knot", err)
875
return
···
878
switch r.Method {
879
case http.MethodGet:
880
// for now, this is just pubkeys
881
+
user := s.oauth.GetUser(r)
882
repoCollaborators, err := f.Collaborators(r.Context(), s)
883
if err != nil {
884
log.Println("failed to get collaborators", err)
···
886
887
isCollaboratorInviteAllowed := false
888
if user != nil {
889
+
ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
890
if err == nil && ok {
891
isCollaboratorInviteAllowed = true
892
}
893
}
894
895
+
var branchNames []string
896
+
var defaultBranch string
897
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
898
+
if err != nil {
899
+
log.Println("failed to create unsigned client", err)
900
+
} else {
901
+
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
902
+
if err != nil {
903
+
log.Println("failed to reach knotserver", err)
904
+
} else {
905
+
defer resp.Body.Close()
906
+
907
+
body, err := io.ReadAll(resp.Body)
908
+
if err != nil {
909
+
log.Printf("Error reading response body: %v", err)
910
+
} else {
911
+
var result types.RepoBranchesResponse
912
+
err = json.Unmarshal(body, &result)
913
+
if err != nil {
914
+
log.Println("failed to parse response:", err)
915
+
} else {
916
+
for _, branch := range result.Branches {
917
+
branchNames = append(branchNames, branch.Name)
918
+
}
919
+
}
920
+
}
921
+
}
922
+
923
+
defaultBranchResp, err := us.DefaultBranch(f.OwnerDid(), f.RepoName)
924
+
if err != nil {
925
+
log.Println("failed to reach knotserver", err)
926
+
} else {
927
+
defaultBranch = defaultBranchResp.Branch
928
+
}
929
+
}
930
s.pages.RepoSettings(w, pages.RepoSettingsParams{
931
LoggedInUser: user,
932
RepoInfo: f.RepoInfo(s, user),
933
Collaborators: repoCollaborators,
934
IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
935
+
Branches: branchNames,
936
+
DefaultBranch: defaultBranch,
937
})
938
}
939
}
···
944
RepoName string
945
RepoAt syntax.ATURI
946
Description string
947
+
CreatedAt string
948
+
Ref string
949
}
950
951
func (f *FullyResolvedRepo) OwnerDid() string {
···
957
}
958
959
func (f *FullyResolvedRepo) OwnerSlashRepo() string {
960
+
handle := f.OwnerId.Handle
961
+
962
+
var p string
963
+
if handle != "" && !handle.IsInvalidHandle() {
964
+
p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName)
965
+
} else {
966
+
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
967
+
}
968
+
969
+
return p
970
+
}
971
+
972
+
func (f *FullyResolvedRepo) DidSlashRepo() string {
973
p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
974
return p
975
}
976
977
func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
978
+
repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
979
if err != nil {
980
return nil, err
981
}
···
1018
return collaborators, nil
1019
}
1020
1021
+
func (f *FullyResolvedRepo) RepoInfo(s *State, u *oauth.User) repoinfo.RepoInfo {
1022
isStarred := false
1023
if u != nil {
1024
isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
···
1036
if err != nil {
1037
log.Println("failed to get issue count for ", f.RepoAt)
1038
}
1039
+
source, err := db.GetRepoSource(s.db, f.RepoAt)
1040
+
if errors.Is(err, sql.ErrNoRows) {
1041
+
source = ""
1042
+
} else if err != nil {
1043
+
log.Println("failed to get repo source for ", f.RepoAt, err)
1044
+
}
1045
+
1046
+
var sourceRepo *db.Repo
1047
+
if source != "" {
1048
+
sourceRepo, err = db.GetRepoByAtUri(s.db, source)
1049
+
if err != nil {
1050
+
log.Println("failed to get repo by at uri", err)
1051
+
}
1052
+
}
1053
+
1054
+
var sourceHandle *identity.Identity
1055
+
if sourceRepo != nil {
1056
+
sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did)
1057
+
if err != nil {
1058
+
log.Println("failed to resolve source repo", err)
1059
+
}
1060
+
}
1061
1062
knot := f.Knot
1063
+
var disableFork bool
1064
+
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
1065
+
if err != nil {
1066
+
log.Printf("failed to create unsigned client for %s: %v", knot, err)
1067
+
} else {
1068
+
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
1069
+
if err != nil {
1070
+
log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
1071
+
} else {
1072
+
defer resp.Body.Close()
1073
+
body, err := io.ReadAll(resp.Body)
1074
+
if err != nil {
1075
+
log.Printf("error reading branch response body: %v", err)
1076
+
} else {
1077
+
var branchesResp types.RepoBranchesResponse
1078
+
if err := json.Unmarshal(body, &branchesResp); err != nil {
1079
+
log.Printf("error parsing branch response: %v", err)
1080
+
} else {
1081
+
disableFork = false
1082
+
}
1083
+
1084
+
if len(branchesResp.Branches) == 0 {
1085
+
disableFork = true
1086
+
}
1087
+
}
1088
+
}
1089
}
1090
1091
+
repoInfo := repoinfo.RepoInfo{
1092
OwnerDid: f.OwnerDid(),
1093
OwnerHandle: f.OwnerHandle(),
1094
Name: f.RepoName,
1095
RepoAt: f.RepoAt,
1096
Description: f.Description,
1097
+
Ref: f.Ref,
1098
IsStarred: isStarred,
1099
Knot: knot,
1100
Roles: RolesInRepo(s, u, f),
···
1103
IssueCount: issueCount,
1104
PullCount: pullCount,
1105
},
1106
+
DisableFork: disableFork,
1107
}
1108
+
1109
+
if sourceRepo != nil {
1110
+
repoInfo.Source = sourceRepo
1111
+
repoInfo.SourceHandle = sourceHandle.Handle.String()
1112
+
}
1113
+
1114
+
return repoInfo
1115
}
1116
1117
func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
1118
+
user := s.oauth.GetUser(r)
1119
+
f, err := s.fullyResolvedRepo(r)
1120
if err != nil {
1121
log.Println("failed to get repo and knot", err)
1122
return
···
1169
}
1170
1171
func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
1172
+
user := s.oauth.GetUser(r)
1173
+
f, err := s.fullyResolvedRepo(r)
1174
if err != nil {
1175
log.Println("failed to get repo and knot", err)
1176
return
···
1205
1206
closed := tangled.RepoIssueStateClosed
1207
1208
+
client, err := s.oauth.AuthorizedClient(r)
1209
+
if err != nil {
1210
+
log.Println("failed to get authorized client", err)
1211
+
return
1212
+
}
1213
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1214
Collection: tangled.RepoIssueStateNSID,
1215
Repo: user.Did,
1216
+
Rkey: appview.TID(),
1217
Record: &lexutil.LexiconTypeDecoder{
1218
Val: &tangled.RepoIssueState{
1219
Issue: issue.IssueAt,
1220
+
State: closed,
1221
},
1222
},
1223
})
···
1228
return
1229
}
1230
1231
+
err = db.CloseIssue(s.db, f.RepoAt, issueIdInt)
1232
if err != nil {
1233
log.Println("failed to close issue", err)
1234
s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
···
1245
}
1246
1247
func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
1248
+
user := s.oauth.GetUser(r)
1249
+
f, err := s.fullyResolvedRepo(r)
1250
if err != nil {
1251
log.Println("failed to get repo and knot", err)
1252
return
···
1292
}
1293
}
1294
1295
+
func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
1296
+
user := s.oauth.GetUser(r)
1297
+
f, err := s.fullyResolvedRepo(r)
1298
if err != nil {
1299
log.Println("failed to get repo and knot", err)
1300
return
···
1316
return
1317
}
1318
1319
+
commentId := mathrand.IntN(1000000)
1320
+
rkey := appview.TID()
1321
1322
+
err := db.NewIssueComment(s.db, &db.Comment{
1323
OwnerDid: user.Did,
1324
RepoAt: f.RepoAt,
1325
Issue: issueIdInt,
1326
CommentId: commentId,
1327
Body: body,
1328
+
Rkey: rkey,
1329
})
1330
if err != nil {
1331
log.Println("failed to create comment", err)
···
1344
}
1345
1346
atUri := f.RepoAt.String()
1347
+
client, err := s.oauth.AuthorizedClient(r)
1348
+
if err != nil {
1349
+
log.Println("failed to get authorized client", err)
1350
+
s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1351
+
return
1352
+
}
1353
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1354
Collection: tangled.RepoIssueCommentNSID,
1355
Repo: user.Did,
1356
+
Rkey: rkey,
1357
Record: &lexutil.LexiconTypeDecoder{
1358
Val: &tangled.RepoIssueComment{
1359
Repo: &atUri,
1360
Issue: issueAt,
1361
CommentId: &commentIdInt64,
1362
Owner: &ownerDid,
1363
+
Body: body,
1364
+
CreatedAt: createdAt,
1365
},
1366
},
1367
})
···
1376
}
1377
}
1378
1379
+
func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1380
+
user := s.oauth.GetUser(r)
1381
+
f, err := s.fullyResolvedRepo(r)
1382
+
if err != nil {
1383
+
log.Println("failed to get repo and knot", err)
1384
+
return
1385
+
}
1386
+
1387
+
issueId := chi.URLParam(r, "issue")
1388
+
issueIdInt, err := strconv.Atoi(issueId)
1389
+
if err != nil {
1390
+
http.Error(w, "bad issue id", http.StatusBadRequest)
1391
+
log.Println("failed to parse issue id", err)
1392
+
return
1393
+
}
1394
+
1395
+
commentId := chi.URLParam(r, "comment_id")
1396
+
commentIdInt, err := strconv.Atoi(commentId)
1397
+
if err != nil {
1398
+
http.Error(w, "bad comment id", http.StatusBadRequest)
1399
+
log.Println("failed to parse issue id", err)
1400
+
return
1401
+
}
1402
+
1403
+
issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1404
+
if err != nil {
1405
+
log.Println("failed to get issue", err)
1406
+
s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1407
+
return
1408
+
}
1409
+
1410
+
comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1411
+
if err != nil {
1412
+
http.Error(w, "bad comment id", http.StatusBadRequest)
1413
+
return
1414
+
}
1415
+
1416
+
identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid)
1417
+
if err != nil {
1418
+
log.Println("failed to resolve did")
1419
+
return
1420
+
}
1421
+
1422
+
didHandleMap := make(map[string]string)
1423
+
if !identity.Handle.IsInvalidHandle() {
1424
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1425
+
} else {
1426
+
didHandleMap[identity.DID.String()] = identity.DID.String()
1427
+
}
1428
+
1429
+
s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1430
+
LoggedInUser: user,
1431
+
RepoInfo: f.RepoInfo(s, user),
1432
+
DidHandleMap: didHandleMap,
1433
+
Issue: issue,
1434
+
Comment: comment,
1435
+
})
1436
+
}
1437
+
1438
+
func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) {
1439
+
user := s.oauth.GetUser(r)
1440
+
f, err := s.fullyResolvedRepo(r)
1441
+
if err != nil {
1442
+
log.Println("failed to get repo and knot", err)
1443
+
return
1444
+
}
1445
+
1446
+
issueId := chi.URLParam(r, "issue")
1447
+
issueIdInt, err := strconv.Atoi(issueId)
1448
+
if err != nil {
1449
+
http.Error(w, "bad issue id", http.StatusBadRequest)
1450
+
log.Println("failed to parse issue id", err)
1451
+
return
1452
+
}
1453
+
1454
+
commentId := chi.URLParam(r, "comment_id")
1455
+
commentIdInt, err := strconv.Atoi(commentId)
1456
+
if err != nil {
1457
+
http.Error(w, "bad comment id", http.StatusBadRequest)
1458
+
log.Println("failed to parse issue id", err)
1459
+
return
1460
+
}
1461
+
1462
+
issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1463
+
if err != nil {
1464
+
log.Println("failed to get issue", err)
1465
+
s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1466
+
return
1467
+
}
1468
+
1469
+
comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1470
+
if err != nil {
1471
+
http.Error(w, "bad comment id", http.StatusBadRequest)
1472
+
return
1473
+
}
1474
+
1475
+
if comment.OwnerDid != user.Did {
1476
+
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1477
+
return
1478
+
}
1479
+
1480
+
switch r.Method {
1481
+
case http.MethodGet:
1482
+
s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
1483
+
LoggedInUser: user,
1484
+
RepoInfo: f.RepoInfo(s, user),
1485
+
Issue: issue,
1486
+
Comment: comment,
1487
+
})
1488
+
case http.MethodPost:
1489
+
// extract form value
1490
+
newBody := r.FormValue("body")
1491
+
client, err := s.oauth.AuthorizedClient(r)
1492
+
if err != nil {
1493
+
log.Println("failed to get authorized client", err)
1494
+
s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1495
+
return
1496
+
}
1497
+
rkey := comment.Rkey
1498
+
1499
+
// optimistic update
1500
+
edited := time.Now()
1501
+
err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
1502
+
if err != nil {
1503
+
log.Println("failed to perferom update-description query", err)
1504
+
s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
1505
+
return
1506
+
}
1507
+
1508
+
// rkey is optional, it was introduced later
1509
+
if comment.Rkey != "" {
1510
+
// update the record on pds
1511
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
1512
+
if err != nil {
1513
+
// failed to get record
1514
+
log.Println(err, rkey)
1515
+
s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
1516
+
return
1517
+
}
1518
+
value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
1519
+
record, _ := data.UnmarshalJSON(value)
1520
+
1521
+
repoAt := record["repo"].(string)
1522
+
issueAt := record["issue"].(string)
1523
+
createdAt := record["createdAt"].(string)
1524
+
commentIdInt64 := int64(commentIdInt)
1525
+
1526
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1527
+
Collection: tangled.RepoIssueCommentNSID,
1528
+
Repo: user.Did,
1529
+
Rkey: rkey,
1530
+
SwapRecord: ex.Cid,
1531
+
Record: &lexutil.LexiconTypeDecoder{
1532
+
Val: &tangled.RepoIssueComment{
1533
+
Repo: &repoAt,
1534
+
Issue: issueAt,
1535
+
CommentId: &commentIdInt64,
1536
+
Owner: &comment.OwnerDid,
1537
+
Body: newBody,
1538
+
CreatedAt: createdAt,
1539
+
},
1540
+
},
1541
+
})
1542
+
if err != nil {
1543
+
log.Println(err)
1544
+
}
1545
+
}
1546
+
1547
+
// optimistic update for htmx
1548
+
didHandleMap := map[string]string{
1549
+
user.Did: user.Handle,
1550
+
}
1551
+
comment.Body = newBody
1552
+
comment.Edited = &edited
1553
+
1554
+
// return new comment body with htmx
1555
+
s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1556
+
LoggedInUser: user,
1557
+
RepoInfo: f.RepoInfo(s, user),
1558
+
DidHandleMap: didHandleMap,
1559
+
Issue: issue,
1560
+
Comment: comment,
1561
+
})
1562
+
return
1563
+
1564
+
}
1565
+
1566
+
}
1567
+
1568
+
func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
1569
+
user := s.oauth.GetUser(r)
1570
+
f, err := s.fullyResolvedRepo(r)
1571
+
if err != nil {
1572
+
log.Println("failed to get repo and knot", err)
1573
+
return
1574
+
}
1575
+
1576
+
issueId := chi.URLParam(r, "issue")
1577
+
issueIdInt, err := strconv.Atoi(issueId)
1578
+
if err != nil {
1579
+
http.Error(w, "bad issue id", http.StatusBadRequest)
1580
+
log.Println("failed to parse issue id", err)
1581
+
return
1582
+
}
1583
+
1584
+
issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1585
+
if err != nil {
1586
+
log.Println("failed to get issue", err)
1587
+
s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1588
+
return
1589
+
}
1590
+
1591
+
commentId := chi.URLParam(r, "comment_id")
1592
+
commentIdInt, err := strconv.Atoi(commentId)
1593
+
if err != nil {
1594
+
http.Error(w, "bad comment id", http.StatusBadRequest)
1595
+
log.Println("failed to parse issue id", err)
1596
+
return
1597
+
}
1598
+
1599
+
comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1600
+
if err != nil {
1601
+
http.Error(w, "bad comment id", http.StatusBadRequest)
1602
+
return
1603
+
}
1604
+
1605
+
if comment.OwnerDid != user.Did {
1606
+
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1607
+
return
1608
+
}
1609
+
1610
+
if comment.Deleted != nil {
1611
+
http.Error(w, "comment already deleted", http.StatusBadRequest)
1612
+
return
1613
+
}
1614
+
1615
+
// optimistic deletion
1616
+
deleted := time.Now()
1617
+
err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1618
+
if err != nil {
1619
+
log.Println("failed to delete comment")
1620
+
s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
1621
+
return
1622
+
}
1623
+
1624
+
// delete from pds
1625
+
if comment.Rkey != "" {
1626
+
client, err := s.oauth.AuthorizedClient(r)
1627
+
if err != nil {
1628
+
log.Println("failed to get authorized client", err)
1629
+
s.pages.Notice(w, "issue-comment", "Failed to delete comment.")
1630
+
return
1631
+
}
1632
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
1633
+
Collection: tangled.GraphFollowNSID,
1634
+
Repo: user.Did,
1635
+
Rkey: comment.Rkey,
1636
+
})
1637
+
if err != nil {
1638
+
log.Println(err)
1639
+
}
1640
+
}
1641
+
1642
+
// optimistic update for htmx
1643
+
didHandleMap := map[string]string{
1644
+
user.Did: user.Handle,
1645
+
}
1646
+
comment.Body = ""
1647
+
comment.Deleted = &deleted
1648
+
1649
+
// htmx fragment of comment after deletion
1650
+
s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1651
+
LoggedInUser: user,
1652
+
RepoInfo: f.RepoInfo(s, user),
1653
+
DidHandleMap: didHandleMap,
1654
+
Issue: issue,
1655
+
Comment: comment,
1656
+
})
1657
+
return
1658
+
}
1659
+
1660
func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
1661
params := r.URL.Query()
1662
state := params.Get("state")
···
1670
isOpen = true
1671
}
1672
1673
+
page, ok := r.Context().Value("page").(pagination.Page)
1674
+
if !ok {
1675
+
log.Println("failed to get page")
1676
+
page = pagination.FirstPage()
1677
+
}
1678
+
1679
+
user := s.oauth.GetUser(r)
1680
+
f, err := s.fullyResolvedRepo(r)
1681
if err != nil {
1682
log.Println("failed to get repo and knot", err)
1683
return
1684
}
1685
1686
+
issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page)
1687
if err != nil {
1688
log.Println("failed to get issues", err)
1689
s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
1705
}
1706
1707
s.pages.RepoIssues(w, pages.RepoIssuesParams{
1708
+
LoggedInUser: s.oauth.GetUser(r),
1709
RepoInfo: f.RepoInfo(s, user),
1710
Issues: issues,
1711
DidHandleMap: didHandleMap,
1712
FilteringByOpen: isOpen,
1713
+
Page: page,
1714
})
1715
return
1716
}
1717
1718
func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
1719
+
user := s.oauth.GetUser(r)
1720
1721
+
f, err := s.fullyResolvedRepo(r)
1722
if err != nil {
1723
log.Println("failed to get repo and knot", err)
1724
return
···
1764
return
1765
}
1766
1767
+
client, err := s.oauth.AuthorizedClient(r)
1768
+
if err != nil {
1769
+
log.Println("failed to get authorized client", err)
1770
+
s.pages.Notice(w, "issues", "Failed to create issue.")
1771
+
return
1772
+
}
1773
atUri := f.RepoAt.String()
1774
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1775
Collection: tangled.RepoIssueNSID,
1776
Repo: user.Did,
1777
+
Rkey: appview.TID(),
1778
Record: &lexutil.LexiconTypeDecoder{
1779
Val: &tangled.RepoIssue{
1780
Repo: atUri,
···
1802
return
1803
}
1804
}
1805
+
1806
+
func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) {
1807
+
user := s.oauth.GetUser(r)
1808
+
f, err := s.fullyResolvedRepo(r)
1809
+
if err != nil {
1810
+
log.Printf("failed to resolve source repo: %v", err)
1811
+
return
1812
+
}
1813
+
1814
+
switch r.Method {
1815
+
case http.MethodGet:
1816
+
user := s.oauth.GetUser(r)
1817
+
knots, err := s.enforcer.GetDomainsForUser(user.Did)
1818
+
if err != nil {
1819
+
s.pages.Notice(w, "repo", "Invalid user account.")
1820
+
return
1821
+
}
1822
+
1823
+
s.pages.ForkRepo(w, pages.ForkRepoParams{
1824
+
LoggedInUser: user,
1825
+
Knots: knots,
1826
+
RepoInfo: f.RepoInfo(s, user),
1827
+
})
1828
+
1829
+
case http.MethodPost:
1830
+
1831
+
knot := r.FormValue("knot")
1832
+
if knot == "" {
1833
+
s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1834
+
return
1835
+
}
1836
+
1837
+
ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1838
+
if err != nil || !ok {
1839
+
s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1840
+
return
1841
+
}
1842
+
1843
+
forkName := fmt.Sprintf("%s", f.RepoName)
1844
+
1845
+
// this check is *only* to see if the forked repo name already exists
1846
+
// in the user's account.
1847
+
existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName)
1848
+
if err != nil {
1849
+
if errors.Is(err, sql.ErrNoRows) {
1850
+
// no existing repo with this name found, we can use the name as is
1851
+
} else {
1852
+
log.Println("error fetching existing repo from db", err)
1853
+
s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1854
+
return
1855
+
}
1856
+
} else if existingRepo != nil {
1857
+
// repo with this name already exists, append random string
1858
+
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1859
+
}
1860
+
secret, err := db.GetRegistrationKey(s.db, knot)
1861
+
if err != nil {
1862
+
s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1863
+
return
1864
+
}
1865
+
1866
+
client, err := knotclient.NewSignedClient(knot, secret, s.config.Core.Dev)
1867
+
if err != nil {
1868
+
s.pages.Notice(w, "repo", "Failed to reach knot server.")
1869
+
return
1870
+
}
1871
+
1872
+
var uri string
1873
+
if s.config.Core.Dev {
1874
+
uri = "http"
1875
+
} else {
1876
+
uri = "https"
1877
+
}
1878
+
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1879
+
sourceAt := f.RepoAt.String()
1880
+
1881
+
rkey := appview.TID()
1882
+
repo := &db.Repo{
1883
+
Did: user.Did,
1884
+
Name: forkName,
1885
+
Knot: knot,
1886
+
Rkey: rkey,
1887
+
Source: sourceAt,
1888
+
}
1889
+
1890
+
tx, err := s.db.BeginTx(r.Context(), nil)
1891
+
if err != nil {
1892
+
log.Println(err)
1893
+
s.pages.Notice(w, "repo", "Failed to save repository information.")
1894
+
return
1895
+
}
1896
+
defer func() {
1897
+
tx.Rollback()
1898
+
err = s.enforcer.E.LoadPolicy()
1899
+
if err != nil {
1900
+
log.Println("failed to rollback policies")
1901
+
}
1902
+
}()
1903
+
1904
+
resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1905
+
if err != nil {
1906
+
s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1907
+
return
1908
+
}
1909
+
1910
+
switch resp.StatusCode {
1911
+
case http.StatusConflict:
1912
+
s.pages.Notice(w, "repo", "A repository with that name already exists.")
1913
+
return
1914
+
case http.StatusInternalServerError:
1915
+
s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1916
+
case http.StatusNoContent:
1917
+
// continue
1918
+
}
1919
+
1920
+
xrpcClient, err := s.oauth.AuthorizedClient(r)
1921
+
if err != nil {
1922
+
log.Println("failed to get authorized client", err)
1923
+
s.pages.Notice(w, "repo", "Failed to create repository.")
1924
+
return
1925
+
}
1926
+
1927
+
createdAt := time.Now().Format(time.RFC3339)
1928
+
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1929
+
Collection: tangled.RepoNSID,
1930
+
Repo: user.Did,
1931
+
Rkey: rkey,
1932
+
Record: &lexutil.LexiconTypeDecoder{
1933
+
Val: &tangled.Repo{
1934
+
Knot: repo.Knot,
1935
+
Name: repo.Name,
1936
+
CreatedAt: createdAt,
1937
+
Owner: user.Did,
1938
+
Source: &sourceAt,
1939
+
}},
1940
+
})
1941
+
if err != nil {
1942
+
log.Printf("failed to create record: %s", err)
1943
+
s.pages.Notice(w, "repo", "Failed to announce repository creation.")
1944
+
return
1945
+
}
1946
+
log.Println("created repo record: ", atresp.Uri)
1947
+
1948
+
repo.AtUri = atresp.Uri
1949
+
err = db.AddRepo(tx, repo)
1950
+
if err != nil {
1951
+
log.Println(err)
1952
+
s.pages.Notice(w, "repo", "Failed to save repository information.")
1953
+
return
1954
+
}
1955
+
1956
+
// acls
1957
+
p, _ := securejoin.SecureJoin(user.Did, forkName)
1958
+
err = s.enforcer.AddRepo(user.Did, knot, p)
1959
+
if err != nil {
1960
+
log.Println(err)
1961
+
s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1962
+
return
1963
+
}
1964
+
1965
+
err = tx.Commit()
1966
+
if err != nil {
1967
+
log.Println("failed to commit changes", err)
1968
+
http.Error(w, err.Error(), http.StatusInternalServerError)
1969
+
return
1970
+
}
1971
+
1972
+
err = s.enforcer.E.SavePolicy()
1973
+
if err != nil {
1974
+
log.Println("failed to update ACLs", err)
1975
+
http.Error(w, err.Error(), http.StatusInternalServerError)
1976
+
return
1977
+
}
1978
+
1979
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1980
+
return
1981
+
}
1982
+
}
+68
-8
appview/state/repo_util.go
+68
-8
appview/state/repo_util.go
···
2
3
import (
4
"context"
5
"fmt"
6
"log"
7
"net/http"
8
9
"github.com/bluesky-social/indigo/atproto/identity"
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
"github.com/go-chi/chi/v5"
12
"github.com/go-git/go-git/v5/plumbing/object"
13
-
"tangled.sh/tangled.sh/core/appview/auth"
14
"tangled.sh/tangled.sh/core/appview/db"
15
-
"tangled.sh/tangled.sh/core/appview/pages"
16
)
17
18
-
func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) {
19
repoName := chi.URLParam(r, "repo")
20
knot, ok := r.Context().Value("knot").(string)
21
if !ok {
···
40
return nil, fmt.Errorf("malformed middleware")
41
}
42
43
// pass through values from the middleware
44
description, ok := r.Context().Value("repoDescription").(string)
45
addedAt, ok := r.Context().Value("repoAddedAt").(string)
···
50
RepoName: repoName,
51
RepoAt: parsedRepoAt,
52
Description: description,
53
-
AddedAt: addedAt,
54
}, nil
55
}
56
57
-
func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo {
58
if u != nil {
59
-
r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo())
60
-
return pages.RolesInRepo{r}
61
} else {
62
-
return pages.RolesInRepo{}
63
}
64
}
65
···
80
return uniqueEmails
81
}
82
83
func EmailToDidOrHandle(s *State, emails []string) map[string]string {
84
emailToDid, err := db.GetEmailToDid(s.db, emails, true) // only get verified emails for mapping
85
if err != nil {
···
112
113
return emailToDidOrHandle
114
}
···
2
3
import (
4
"context"
5
+
"crypto/rand"
6
"fmt"
7
"log"
8
+
"math/big"
9
"net/http"
10
11
"github.com/bluesky-social/indigo/atproto/identity"
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
"github.com/go-chi/chi/v5"
14
"github.com/go-git/go-git/v5/plumbing/object"
15
"tangled.sh/tangled.sh/core/appview/db"
16
+
"tangled.sh/tangled.sh/core/appview/knotclient"
17
+
"tangled.sh/tangled.sh/core/appview/oauth"
18
+
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
19
)
20
21
+
func (s *State) fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) {
22
repoName := chi.URLParam(r, "repo")
23
knot, ok := r.Context().Value("knot").(string)
24
if !ok {
···
43
return nil, fmt.Errorf("malformed middleware")
44
}
45
46
+
ref := chi.URLParam(r, "ref")
47
+
48
+
if ref == "" {
49
+
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
50
+
if err != nil {
51
+
return nil, err
52
+
}
53
+
54
+
defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName)
55
+
if err != nil {
56
+
return nil, err
57
+
}
58
+
59
+
ref = defaultBranch.Branch
60
+
}
61
+
62
// pass through values from the middleware
63
description, ok := r.Context().Value("repoDescription").(string)
64
addedAt, ok := r.Context().Value("repoAddedAt").(string)
···
69
RepoName: repoName,
70
RepoAt: parsedRepoAt,
71
Description: description,
72
+
CreatedAt: addedAt,
73
+
Ref: ref,
74
}, nil
75
}
76
77
+
func RolesInRepo(s *State, u *oauth.User, f *FullyResolvedRepo) repoinfo.RolesInRepo {
78
if u != nil {
79
+
r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
80
+
return repoinfo.RolesInRepo{r}
81
} else {
82
+
return repoinfo.RolesInRepo{}
83
}
84
}
85
···
100
return uniqueEmails
101
}
102
103
+
func balanceIndexItems(commitCount, branchCount, tagCount, fileCount int) (commitsTrunc int, branchesTrunc int, tagsTrunc int) {
104
+
if commitCount == 0 && tagCount == 0 && branchCount == 0 {
105
+
return
106
+
}
107
+
108
+
// typically 1 item on right side = 2 files in height
109
+
availableSpace := fileCount / 2
110
+
111
+
// clamp tagcount
112
+
if tagCount > 0 {
113
+
tagsTrunc = 1
114
+
availableSpace -= 1 // an extra subtracted for headers etc.
115
+
}
116
+
117
+
// clamp branchcount
118
+
if branchCount > 0 {
119
+
branchesTrunc = min(max(branchCount, 1), 2)
120
+
availableSpace -= branchesTrunc // an extra subtracted for headers etc.
121
+
}
122
+
123
+
// show
124
+
if commitCount > 0 {
125
+
commitsTrunc = max(availableSpace, 3)
126
+
}
127
+
128
+
return
129
+
}
130
+
131
func EmailToDidOrHandle(s *State, emails []string) map[string]string {
132
emailToDid, err := db.GetEmailToDid(s.db, emails, true) // only get verified emails for mapping
133
if err != nil {
···
160
161
return emailToDidOrHandle
162
}
163
+
164
+
func randomString(n int) string {
165
+
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
166
+
result := make([]byte, n)
167
+
168
+
for i := 0; i < n; i++ {
169
+
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
170
+
result[i] = letters[n.Int64()]
171
+
}
172
+
173
+
return string(result)
174
+
}
+96
-34
appview/state/router.go
+96
-34
appview/state/router.go
···
5
"strings"
6
7
"github.com/go-chi/chi/v5"
8
"tangled.sh/tangled.sh/core/appview/state/userutil"
9
)
10
···
51
r.Use(StripLeadingAt)
52
53
r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) {
54
-
r.Get("/", s.ProfilePage)
55
r.With(ResolveRepo(s)).Route("/{repo}", func(r chi.Router) {
56
r.Get("/", s.RepoIndex)
57
r.Get("/commits/{ref}", s.RepoLog)
58
r.Route("/tree/{ref}", func(r chi.Router) {
···
61
})
62
r.Get("/commit/{ref}", s.RepoCommit)
63
r.Get("/branches", s.RepoBranches)
64
-
r.Get("/tags", s.RepoTags)
65
r.Get("/blob/{ref}/*", s.RepoBlob)
66
67
r.Route("/issues", func(r chi.Router) {
68
-
r.Get("/", s.RepoIssues)
69
r.Get("/{issue}", s.RepoSingleIssue)
70
71
r.Group(func(r chi.Router) {
72
-
r.Use(AuthMiddleware(s))
73
r.Get("/new", s.NewIssue)
74
r.Post("/new", s.NewIssue)
75
-
r.Post("/{issue}/comment", s.IssueComment)
76
r.Post("/{issue}/close", s.CloseIssue)
77
r.Post("/{issue}/reopen", s.ReopenIssue)
78
})
79
})
80
81
r.Route("/pulls", func(r chi.Router) {
82
r.Get("/", s.RepoPulls)
83
-
r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) {
84
r.Get("/", s.NewPull)
85
r.Post("/", s.NewPull)
86
})
87
···
91
92
r.Route("/round/{round}", func(r chi.Router) {
93
r.Get("/", s.RepoPullPatch)
94
r.Get("/actions", s.PullActions)
95
-
r.Route("/comment", func(r chi.Router) {
96
r.Get("/", s.PullComment)
97
r.Post("/", s.PullComment)
98
})
99
})
100
101
-
// authorized requests below this point
102
r.Group(func(r chi.Router) {
103
-
r.Use(AuthMiddleware(s))
104
r.Route("/resubmit", func(r chi.Router) {
105
r.Get("/", s.ResubmitPull)
106
r.Post("/", s.ResubmitPull)
107
})
108
-
r.Route("/comment", func(r chi.Router) {
109
-
r.Get("/", s.PullComment)
110
-
r.Post("/", s.PullComment)
111
-
})
112
r.Post("/close", s.ClosePull)
113
r.Post("/reopen", s.ReopenPull)
114
// collaborators only
···
127
128
// settings routes, needs auth
129
r.Group(func(r chi.Router) {
130
-
r.Use(AuthMiddleware(s))
131
// repo description can only be edited by owner
132
r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) {
133
r.Put("/", s.RepoDescription)
···
137
r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) {
138
r.Get("/", s.RepoSettings)
139
r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator)
140
})
141
})
142
})
···
156
157
r.Get("/", s.Timeline)
158
159
-
r.With(AuthMiddleware(s)).Get("/logout", s.Logout)
160
-
161
-
r.Route("/login", func(r chi.Router) {
162
-
r.Get("/", s.Login)
163
-
r.Post("/", s.Login)
164
-
})
165
166
r.Route("/knots", func(r chi.Router) {
167
-
r.Use(AuthMiddleware(s))
168
r.Get("/", s.Knots)
169
r.Post("/key", s.RegistrationKey)
170
···
182
183
r.Route("/repo", func(r chi.Router) {
184
r.Route("/new", func(r chi.Router) {
185
-
r.Use(AuthMiddleware(s))
186
r.Get("/", s.NewRepo)
187
r.Post("/", s.NewRepo)
188
})
189
// r.Post("/import", s.ImportRepo)
190
})
191
192
-
r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) {
193
r.Post("/", s.Follow)
194
r.Delete("/", s.Follow)
195
})
196
197
-
r.With(AuthMiddleware(s)).Route("/star", func(r chi.Router) {
198
r.Post("/", s.Star)
199
r.Delete("/", s.Star)
200
})
201
202
-
r.Route("/settings", func(r chi.Router) {
203
-
r.Use(AuthMiddleware(s))
204
-
r.Get("/", s.Settings)
205
-
r.Put("/keys", s.SettingsKeys)
206
-
r.Delete("/keys", s.SettingsKeys)
207
-
r.Put("/emails", s.SettingsEmails)
208
-
r.Delete("/emails", s.SettingsEmails)
209
-
r.Get("/emails/verify", s.SettingsEmailsVerify)
210
-
r.Post("/emails/verify/resend", s.SettingsEmailsVerifyResend)
211
-
r.Post("/emails/primary", s.SettingsEmailsPrimary)
212
})
213
214
r.Get("/keys/{user}", s.Keys)
215
216
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
···
218
})
219
return r
220
}
···
5
"strings"
6
7
"github.com/go-chi/chi/v5"
8
+
"github.com/gorilla/sessions"
9
+
"tangled.sh/tangled.sh/core/appview/middleware"
10
+
oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler"
11
+
"tangled.sh/tangled.sh/core/appview/settings"
12
"tangled.sh/tangled.sh/core/appview/state/userutil"
13
)
14
···
55
r.Use(StripLeadingAt)
56
57
r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) {
58
+
r.Get("/", s.Profile)
59
+
60
r.With(ResolveRepo(s)).Route("/{repo}", func(r chi.Router) {
61
+
r.Use(GoImport(s))
62
+
63
r.Get("/", s.RepoIndex)
64
r.Get("/commits/{ref}", s.RepoLog)
65
r.Route("/tree/{ref}", func(r chi.Router) {
···
68
})
69
r.Get("/commit/{ref}", s.RepoCommit)
70
r.Get("/branches", s.RepoBranches)
71
+
r.Route("/tags", func(r chi.Router) {
72
+
r.Get("/", s.RepoTags)
73
+
r.Route("/{tag}", func(r chi.Router) {
74
+
r.Use(middleware.AuthMiddleware(s.oauth))
75
+
// require auth to download for now
76
+
r.Get("/download/{file}", s.DownloadArtifact)
77
+
78
+
// require repo:push to upload or delete artifacts
79
+
//
80
+
// additionally: only the uploader can truly delete an artifact
81
+
// (record+blob will live on their pds)
82
+
r.Group(func(r chi.Router) {
83
+
r.With(RepoPermissionMiddleware(s, "repo:push"))
84
+
r.Post("/upload", s.AttachArtifact)
85
+
r.Delete("/{file}", s.DeleteArtifact)
86
+
})
87
+
})
88
+
})
89
r.Get("/blob/{ref}/*", s.RepoBlob)
90
+
r.Get("/raw/{ref}/*", s.RepoBlobRaw)
91
92
r.Route("/issues", func(r chi.Router) {
93
+
r.With(middleware.Paginate).Get("/", s.RepoIssues)
94
r.Get("/{issue}", s.RepoSingleIssue)
95
96
r.Group(func(r chi.Router) {
97
+
r.Use(middleware.AuthMiddleware(s.oauth))
98
r.Get("/new", s.NewIssue)
99
r.Post("/new", s.NewIssue)
100
+
r.Post("/{issue}/comment", s.NewIssueComment)
101
+
r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) {
102
+
r.Get("/", s.IssueComment)
103
+
r.Delete("/", s.DeleteIssueComment)
104
+
r.Get("/edit", s.EditIssueComment)
105
+
r.Post("/edit", s.EditIssueComment)
106
+
})
107
r.Post("/{issue}/close", s.CloseIssue)
108
r.Post("/{issue}/reopen", s.ReopenIssue)
109
})
110
})
111
112
+
r.Route("/fork", func(r chi.Router) {
113
+
r.Use(middleware.AuthMiddleware(s.oauth))
114
+
r.Get("/", s.ForkRepo)
115
+
r.Post("/", s.ForkRepo)
116
+
})
117
+
118
r.Route("/pulls", func(r chi.Router) {
119
r.Get("/", s.RepoPulls)
120
+
r.With(middleware.AuthMiddleware(s.oauth)).Route("/new", func(r chi.Router) {
121
r.Get("/", s.NewPull)
122
+
r.Get("/patch-upload", s.PatchUploadFragment)
123
+
r.Post("/validate-patch", s.ValidatePatch)
124
+
r.Get("/compare-branches", s.CompareBranchesFragment)
125
+
r.Get("/compare-forks", s.CompareForksFragment)
126
+
r.Get("/fork-branches", s.CompareForksBranchesFragment)
127
r.Post("/", s.NewPull)
128
})
129
···
133
134
r.Route("/round/{round}", func(r chi.Router) {
135
r.Get("/", s.RepoPullPatch)
136
+
r.Get("/interdiff", s.RepoPullInterdiff)
137
r.Get("/actions", s.PullActions)
138
+
r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) {
139
r.Get("/", s.PullComment)
140
r.Post("/", s.PullComment)
141
})
142
})
143
144
+
r.Route("/round/{round}.patch", func(r chi.Router) {
145
+
r.Get("/", s.RepoPullPatchRaw)
146
+
})
147
+
148
r.Group(func(r chi.Router) {
149
+
r.Use(middleware.AuthMiddleware(s.oauth))
150
r.Route("/resubmit", func(r chi.Router) {
151
r.Get("/", s.ResubmitPull)
152
r.Post("/", s.ResubmitPull)
153
})
154
r.Post("/close", s.ClosePull)
155
r.Post("/reopen", s.ReopenPull)
156
// collaborators only
···
169
170
// settings routes, needs auth
171
r.Group(func(r chi.Router) {
172
+
r.Use(middleware.AuthMiddleware(s.oauth))
173
// repo description can only be edited by owner
174
r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) {
175
r.Put("/", s.RepoDescription)
···
179
r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) {
180
r.Get("/", s.RepoSettings)
181
r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator)
182
+
r.With(RepoPermissionMiddleware(s, "repo:delete")).Delete("/delete", s.DeleteRepo)
183
+
r.Put("/branches/default", s.SetDefaultBranch)
184
})
185
})
186
})
···
200
201
r.Get("/", s.Timeline)
202
203
+
r.With(middleware.AuthMiddleware(s.oauth)).Post("/logout", s.Logout)
204
205
r.Route("/knots", func(r chi.Router) {
206
+
r.Use(middleware.AuthMiddleware(s.oauth))
207
r.Get("/", s.Knots)
208
r.Post("/key", s.RegistrationKey)
209
···
221
222
r.Route("/repo", func(r chi.Router) {
223
r.Route("/new", func(r chi.Router) {
224
+
r.Use(middleware.AuthMiddleware(s.oauth))
225
r.Get("/", s.NewRepo)
226
r.Post("/", s.NewRepo)
227
})
228
// r.Post("/import", s.ImportRepo)
229
})
230
231
+
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
232
r.Post("/", s.Follow)
233
r.Delete("/", s.Follow)
234
})
235
236
+
r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) {
237
r.Post("/", s.Star)
238
r.Delete("/", s.Star)
239
})
240
241
+
r.Route("/profile", func(r chi.Router) {
242
+
r.Use(middleware.AuthMiddleware(s.oauth))
243
+
r.Get("/edit-bio", s.EditBioFragment)
244
+
r.Get("/edit-pins", s.EditPinsFragment)
245
+
r.Post("/bio", s.UpdateProfileBio)
246
+
r.Post("/pins", s.UpdateProfilePins)
247
})
248
249
+
r.Mount("/settings", s.SettingsRouter())
250
+
r.Mount("/", s.OAuthRouter())
251
r.Get("/keys/{user}", s.Keys)
252
253
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
···
255
})
256
return r
257
}
258
+
259
+
func (s *State) OAuthRouter() http.Handler {
260
+
oauth := &oauthhandler.OAuthHandler{
261
+
Config: s.config,
262
+
Pages: s.pages,
263
+
Resolver: s.resolver,
264
+
Db: s.db,
265
+
Store: sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)),
266
+
OAuth: s.oauth,
267
+
Enforcer: s.enforcer,
268
+
}
269
+
270
+
return oauth.Router()
271
+
}
272
+
273
+
func (s *State) SettingsRouter() http.Handler {
274
+
settings := &settings.Settings{
275
+
Db: s.db,
276
+
OAuth: s.oauth,
277
+
Pages: s.pages,
278
+
Config: s.config,
279
+
}
280
+
281
+
return settings.Router()
282
+
}
-416
appview/state/settings.go
-416
appview/state/settings.go
···
1
-
package state
2
-
3
-
import (
4
-
"database/sql"
5
-
"errors"
6
-
"fmt"
7
-
"log"
8
-
"net/http"
9
-
"net/url"
10
-
"strings"
11
-
"time"
12
-
13
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
-
lexutil "github.com/bluesky-social/indigo/lex/util"
15
-
"github.com/gliderlabs/ssh"
16
-
"github.com/google/uuid"
17
-
"tangled.sh/tangled.sh/core/api/tangled"
18
-
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/email"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
-
)
22
-
23
-
func (s *State) Settings(w http.ResponseWriter, r *http.Request) {
24
-
user := s.auth.GetUser(r)
25
-
pubKeys, err := db.GetPublicKeys(s.db, user.Did)
26
-
if err != nil {
27
-
log.Println(err)
28
-
}
29
-
30
-
emails, err := db.GetAllEmails(s.db, user.Did)
31
-
if err != nil {
32
-
log.Println(err)
33
-
}
34
-
35
-
s.pages.Settings(w, pages.SettingsParams{
36
-
LoggedInUser: user,
37
-
PubKeys: pubKeys,
38
-
Emails: emails,
39
-
})
40
-
}
41
-
42
-
// buildVerificationEmail creates an email.Email struct for verification emails
43
-
func (s *State) buildVerificationEmail(emailAddr, did, code string) email.Email {
44
-
verifyURL := s.verifyUrl(did, emailAddr, code)
45
-
46
-
return email.Email{
47
-
APIKey: s.config.ResendApiKey,
48
-
From: "noreply@notifs.tangled.sh",
49
-
To: emailAddr,
50
-
Subject: "Verify your Tangled email",
51
-
Text: `Click the link below (or copy and paste it into your browser) to verify your email address.
52
-
` + verifyURL,
53
-
Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>
54
-
<p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`,
55
-
}
56
-
}
57
-
58
-
// sendVerificationEmail handles the common logic for sending verification emails
59
-
func (s *State) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error {
60
-
emailToSend := s.buildVerificationEmail(emailAddr, did, code)
61
-
62
-
err := email.SendEmail(emailToSend)
63
-
if err != nil {
64
-
log.Printf("sending email: %s", err)
65
-
s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext))
66
-
return err
67
-
}
68
-
69
-
return nil
70
-
}
71
-
72
-
func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) {
73
-
switch r.Method {
74
-
case http.MethodGet:
75
-
s.pages.Notice(w, "settings-emails", "Unimplemented.")
76
-
log.Println("unimplemented")
77
-
return
78
-
case http.MethodPut:
79
-
did := s.auth.GetDid(r)
80
-
emAddr := r.FormValue("email")
81
-
emAddr = strings.TrimSpace(emAddr)
82
-
83
-
if !email.IsValidEmail(emAddr) {
84
-
s.pages.Notice(w, "settings-emails-error", "Invalid email address.")
85
-
return
86
-
}
87
-
88
-
// check if email already exists in database
89
-
existingEmail, err := db.GetEmail(s.db, did, emAddr)
90
-
if err != nil && !errors.Is(err, sql.ErrNoRows) {
91
-
log.Printf("checking for existing email: %s", err)
92
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
93
-
return
94
-
}
95
-
96
-
if err == nil {
97
-
if existingEmail.Verified {
98
-
s.pages.Notice(w, "settings-emails-error", "This email is already verified.")
99
-
return
100
-
}
101
-
102
-
s.pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.")
103
-
return
104
-
}
105
-
106
-
code := uuid.New().String()
107
-
108
-
// Begin transaction
109
-
tx, err := s.db.Begin()
110
-
if err != nil {
111
-
log.Printf("failed to start transaction: %s", err)
112
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
113
-
return
114
-
}
115
-
defer tx.Rollback()
116
-
117
-
if err := db.AddEmail(tx, db.Email{
118
-
Did: did,
119
-
Address: emAddr,
120
-
Verified: false,
121
-
VerificationCode: code,
122
-
}); err != nil {
123
-
log.Printf("adding email: %s", err)
124
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
125
-
return
126
-
}
127
-
128
-
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
129
-
return
130
-
}
131
-
132
-
// Commit transaction
133
-
if err := tx.Commit(); err != nil {
134
-
log.Printf("failed to commit transaction: %s", err)
135
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
136
-
return
137
-
}
138
-
139
-
s.pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.")
140
-
return
141
-
case http.MethodDelete:
142
-
did := s.auth.GetDid(r)
143
-
emailAddr := r.FormValue("email")
144
-
emailAddr = strings.TrimSpace(emailAddr)
145
-
146
-
// Begin transaction
147
-
tx, err := s.db.Begin()
148
-
if err != nil {
149
-
log.Printf("failed to start transaction: %s", err)
150
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
151
-
return
152
-
}
153
-
defer tx.Rollback()
154
-
155
-
if err := db.DeleteEmail(tx, did, emailAddr); err != nil {
156
-
log.Printf("deleting email: %s", err)
157
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
158
-
return
159
-
}
160
-
161
-
// Commit transaction
162
-
if err := tx.Commit(); err != nil {
163
-
log.Printf("failed to commit transaction: %s", err)
164
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
165
-
return
166
-
}
167
-
168
-
s.pages.HxLocation(w, "/settings")
169
-
return
170
-
}
171
-
}
172
-
173
-
func (s *State) verifyUrl(did string, email string, code string) string {
174
-
var appUrl string
175
-
if s.config.Dev {
176
-
appUrl = "http://" + s.config.ListenAddr
177
-
} else {
178
-
appUrl = "https://tangled.sh"
179
-
}
180
-
181
-
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
182
-
}
183
-
184
-
func (s *State) SettingsEmailsVerify(w http.ResponseWriter, r *http.Request) {
185
-
q := r.URL.Query()
186
-
187
-
// Get the parameters directly from the query
188
-
emailAddr := q.Get("email")
189
-
did := q.Get("did")
190
-
code := q.Get("code")
191
-
192
-
valid, err := db.CheckValidVerificationCode(s.db, did, emailAddr, code)
193
-
if err != nil {
194
-
log.Printf("checking email verification: %s", err)
195
-
s.pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.")
196
-
return
197
-
}
198
-
199
-
if !valid {
200
-
s.pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.")
201
-
return
202
-
}
203
-
204
-
// Mark email as verified in the database
205
-
if err := db.MarkEmailVerified(s.db, did, emailAddr); err != nil {
206
-
log.Printf("marking email as verified: %s", err)
207
-
s.pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.")
208
-
return
209
-
}
210
-
211
-
http.Redirect(w, r, "/settings", http.StatusSeeOther)
212
-
}
213
-
214
-
func (s *State) SettingsEmailsVerifyResend(w http.ResponseWriter, r *http.Request) {
215
-
if r.Method != http.MethodPost {
216
-
s.pages.Notice(w, "settings-emails-error", "Invalid request method.")
217
-
return
218
-
}
219
-
220
-
did := s.auth.GetDid(r)
221
-
emAddr := r.FormValue("email")
222
-
emAddr = strings.TrimSpace(emAddr)
223
-
224
-
if !email.IsValidEmail(emAddr) {
225
-
s.pages.Notice(w, "settings-emails-error", "Invalid email address.")
226
-
return
227
-
}
228
-
229
-
// Check if email exists and is unverified
230
-
existingEmail, err := db.GetEmail(s.db, did, emAddr)
231
-
if err != nil {
232
-
if errors.Is(err, sql.ErrNoRows) {
233
-
s.pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.")
234
-
} else {
235
-
log.Printf("checking for existing email: %s", err)
236
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
237
-
}
238
-
return
239
-
}
240
-
241
-
if existingEmail.Verified {
242
-
s.pages.Notice(w, "settings-emails-error", "This email is already verified.")
243
-
return
244
-
}
245
-
246
-
// Check if last verification email was sent less than 10 minutes ago
247
-
if existingEmail.LastSent != nil {
248
-
timeSinceLastSent := time.Since(*existingEmail.LastSent)
249
-
if timeSinceLastSent < 10*time.Minute {
250
-
waitTime := 10*time.Minute - timeSinceLastSent
251
-
s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1)))
252
-
return
253
-
}
254
-
}
255
-
256
-
// Generate new verification code
257
-
code := uuid.New().String()
258
-
259
-
// Begin transaction
260
-
tx, err := s.db.Begin()
261
-
if err != nil {
262
-
log.Printf("failed to start transaction: %s", err)
263
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
264
-
return
265
-
}
266
-
defer tx.Rollback()
267
-
268
-
// Update the verification code and last sent time
269
-
if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil {
270
-
log.Printf("updating email verification: %s", err)
271
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
272
-
return
273
-
}
274
-
275
-
// Send verification email
276
-
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
277
-
return
278
-
}
279
-
280
-
// Commit transaction
281
-
if err := tx.Commit(); err != nil {
282
-
log.Printf("failed to commit transaction: %s", err)
283
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
284
-
return
285
-
}
286
-
287
-
s.pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.")
288
-
}
289
-
290
-
func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) {
291
-
did := s.auth.GetDid(r)
292
-
emailAddr := r.FormValue("email")
293
-
emailAddr = strings.TrimSpace(emailAddr)
294
-
295
-
if emailAddr == "" {
296
-
s.pages.Notice(w, "settings-emails-error", "Email address cannot be empty.")
297
-
return
298
-
}
299
-
300
-
if err := db.MakeEmailPrimary(s.db, did, emailAddr); err != nil {
301
-
log.Printf("setting primary email: %s", err)
302
-
s.pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.")
303
-
return
304
-
}
305
-
306
-
s.pages.HxLocation(w, "/settings")
307
-
}
308
-
309
-
func (s *State) SettingsKeys(w http.ResponseWriter, r *http.Request) {
310
-
switch r.Method {
311
-
case http.MethodGet:
312
-
s.pages.Notice(w, "settings-keys", "Unimplemented.")
313
-
log.Println("unimplemented")
314
-
return
315
-
case http.MethodPut:
316
-
did := s.auth.GetDid(r)
317
-
key := r.FormValue("key")
318
-
key = strings.TrimSpace(key)
319
-
name := r.FormValue("name")
320
-
client, _ := s.auth.AuthorizedClient(r)
321
-
322
-
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
323
-
if err != nil {
324
-
log.Printf("parsing public key: %s", err)
325
-
s.pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.")
326
-
return
327
-
}
328
-
329
-
rkey := s.TID()
330
-
331
-
tx, err := s.db.Begin()
332
-
if err != nil {
333
-
log.Printf("failed to start tx; adding public key: %s", err)
334
-
s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
335
-
return
336
-
}
337
-
defer tx.Rollback()
338
-
339
-
if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil {
340
-
log.Printf("adding public key: %s", err)
341
-
s.pages.Notice(w, "settings-keys", "Failed to add public key.")
342
-
return
343
-
}
344
-
345
-
// store in pds too
346
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
347
-
Collection: tangled.PublicKeyNSID,
348
-
Repo: did,
349
-
Rkey: rkey,
350
-
Record: &lexutil.LexiconTypeDecoder{
351
-
Val: &tangled.PublicKey{
352
-
Created: time.Now().Format(time.RFC3339),
353
-
Key: key,
354
-
Name: name,
355
-
}},
356
-
})
357
-
// invalid record
358
-
if err != nil {
359
-
log.Printf("failed to create record: %s", err)
360
-
s.pages.Notice(w, "settings-keys", "Failed to create record.")
361
-
return
362
-
}
363
-
364
-
log.Println("created atproto record: ", resp.Uri)
365
-
366
-
err = tx.Commit()
367
-
if err != nil {
368
-
log.Printf("failed to commit tx; adding public key: %s", err)
369
-
s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
370
-
return
371
-
}
372
-
373
-
s.pages.HxLocation(w, "/settings")
374
-
return
375
-
376
-
case http.MethodDelete:
377
-
did := s.auth.GetDid(r)
378
-
q := r.URL.Query()
379
-
380
-
name := q.Get("name")
381
-
rkey := q.Get("rkey")
382
-
key := q.Get("key")
383
-
384
-
log.Println(name)
385
-
log.Println(rkey)
386
-
log.Println(key)
387
-
388
-
client, _ := s.auth.AuthorizedClient(r)
389
-
390
-
if err := db.RemovePublicKey(s.db, did, name, key); err != nil {
391
-
log.Printf("removing public key: %s", err)
392
-
s.pages.Notice(w, "settings-keys", "Failed to remove public key.")
393
-
return
394
-
}
395
-
396
-
if rkey != "" {
397
-
// remove from pds too
398
-
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
399
-
Collection: tangled.PublicKeyNSID,
400
-
Repo: did,
401
-
Rkey: rkey,
402
-
})
403
-
404
-
// invalid record
405
-
if err != nil {
406
-
log.Printf("failed to delete record from PDS: %s", err)
407
-
s.pages.Notice(w, "settings-keys", "Failed to remove key from PDS.")
408
-
return
409
-
}
410
-
}
411
-
log.Println("deleted successfully")
412
-
413
-
s.pages.HxLocation(w, "/settings")
414
-
return
415
-
}
416
-
}
···
-270
appview/state/signer.go
-270
appview/state/signer.go
···
1
-
package state
2
-
3
-
import (
4
-
"bytes"
5
-
"crypto/hmac"
6
-
"crypto/sha256"
7
-
"encoding/hex"
8
-
"encoding/json"
9
-
"fmt"
10
-
"net/http"
11
-
"net/url"
12
-
"time"
13
-
14
-
"tangled.sh/tangled.sh/core/types"
15
-
)
16
-
17
-
type SignerTransport struct {
18
-
Secret string
19
-
}
20
-
21
-
func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
22
-
timestamp := time.Now().Format(time.RFC3339)
23
-
mac := hmac.New(sha256.New, []byte(s.Secret))
24
-
message := req.Method + req.URL.Path + timestamp
25
-
mac.Write([]byte(message))
26
-
signature := hex.EncodeToString(mac.Sum(nil))
27
-
req.Header.Set("X-Signature", signature)
28
-
req.Header.Set("X-Timestamp", timestamp)
29
-
return http.DefaultTransport.RoundTrip(req)
30
-
}
31
-
32
-
type SignedClient struct {
33
-
Secret string
34
-
Url *url.URL
35
-
client *http.Client
36
-
}
37
-
38
-
func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) {
39
-
client := &http.Client{
40
-
Timeout: 5 * time.Second,
41
-
Transport: SignerTransport{
42
-
Secret: secret,
43
-
},
44
-
}
45
-
46
-
scheme := "https"
47
-
if dev {
48
-
scheme = "http"
49
-
}
50
-
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
51
-
if err != nil {
52
-
return nil, err
53
-
}
54
-
55
-
signedClient := &SignedClient{
56
-
Secret: secret,
57
-
client: client,
58
-
Url: url,
59
-
}
60
-
61
-
return signedClient, nil
62
-
}
63
-
64
-
func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) {
65
-
return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body))
66
-
}
67
-
68
-
func (s *SignedClient) Init(did string) (*http.Response, error) {
69
-
const (
70
-
Method = "POST"
71
-
Endpoint = "/init"
72
-
)
73
-
74
-
body, _ := json.Marshal(map[string]any{
75
-
"did": did,
76
-
})
77
-
78
-
req, err := s.newRequest(Method, Endpoint, body)
79
-
if err != nil {
80
-
return nil, err
81
-
}
82
-
83
-
return s.client.Do(req)
84
-
}
85
-
86
-
func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) {
87
-
const (
88
-
Method = "PUT"
89
-
Endpoint = "/repo/new"
90
-
)
91
-
92
-
body, _ := json.Marshal(map[string]any{
93
-
"did": did,
94
-
"name": repoName,
95
-
"default_branch": defaultBranch,
96
-
})
97
-
98
-
req, err := s.newRequest(Method, Endpoint, body)
99
-
if err != nil {
100
-
return nil, err
101
-
}
102
-
103
-
return s.client.Do(req)
104
-
}
105
-
106
-
func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) {
107
-
const (
108
-
Method = "DELETE"
109
-
Endpoint = "/repo"
110
-
)
111
-
112
-
body, _ := json.Marshal(map[string]any{
113
-
"did": did,
114
-
"name": repoName,
115
-
})
116
-
117
-
req, err := s.newRequest(Method, Endpoint, body)
118
-
if err != nil {
119
-
return nil, err
120
-
}
121
-
122
-
return s.client.Do(req)
123
-
}
124
-
125
-
func (s *SignedClient) AddMember(did string) (*http.Response, error) {
126
-
const (
127
-
Method = "PUT"
128
-
Endpoint = "/member/add"
129
-
)
130
-
131
-
body, _ := json.Marshal(map[string]any{
132
-
"did": did,
133
-
})
134
-
135
-
req, err := s.newRequest(Method, Endpoint, body)
136
-
if err != nil {
137
-
return nil, err
138
-
}
139
-
140
-
return s.client.Do(req)
141
-
}
142
-
143
-
func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) {
144
-
const (
145
-
Method = "POST"
146
-
)
147
-
endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName)
148
-
149
-
body, _ := json.Marshal(map[string]any{
150
-
"did": memberDid,
151
-
})
152
-
153
-
req, err := s.newRequest(Method, endpoint, body)
154
-
if err != nil {
155
-
return nil, err
156
-
}
157
-
158
-
return s.client.Do(req)
159
-
}
160
-
161
-
func (s *SignedClient) Merge(
162
-
patch []byte,
163
-
ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string,
164
-
) (*http.Response, error) {
165
-
const (
166
-
Method = "POST"
167
-
)
168
-
endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo)
169
-
170
-
mr := types.MergeRequest{
171
-
Branch: branch,
172
-
CommitMessage: commitMessage,
173
-
CommitBody: commitBody,
174
-
AuthorName: authorName,
175
-
AuthorEmail: authorEmail,
176
-
Patch: string(patch),
177
-
}
178
-
179
-
body, _ := json.Marshal(mr)
180
-
181
-
req, err := s.newRequest(Method, endpoint, body)
182
-
if err != nil {
183
-
return nil, err
184
-
}
185
-
186
-
return s.client.Do(req)
187
-
}
188
-
189
-
func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) {
190
-
const (
191
-
Method = "POST"
192
-
)
193
-
endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo)
194
-
195
-
body, _ := json.Marshal(map[string]any{
196
-
"patch": string(patch),
197
-
"branch": branch,
198
-
})
199
-
200
-
req, err := s.newRequest(Method, endpoint, body)
201
-
if err != nil {
202
-
return nil, err
203
-
}
204
-
205
-
return s.client.Do(req)
206
-
}
207
-
208
-
type UnsignedClient struct {
209
-
Url *url.URL
210
-
client *http.Client
211
-
}
212
-
213
-
func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) {
214
-
client := &http.Client{
215
-
Timeout: 5 * time.Second,
216
-
}
217
-
218
-
scheme := "https"
219
-
if dev {
220
-
scheme = "http"
221
-
}
222
-
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
223
-
if err != nil {
224
-
return nil, err
225
-
}
226
-
227
-
unsignedClient := &UnsignedClient{
228
-
client: client,
229
-
Url: url,
230
-
}
231
-
232
-
return unsignedClient, nil
233
-
}
234
-
235
-
func (us *UnsignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) {
236
-
return http.NewRequest(method, us.Url.JoinPath(endpoint).String(), bytes.NewReader(body))
237
-
}
238
-
239
-
func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*http.Response, error) {
240
-
const (
241
-
Method = "GET"
242
-
)
243
-
244
-
endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref)
245
-
if ref == "" {
246
-
endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName)
247
-
}
248
-
249
-
req, err := us.newRequest(Method, endpoint, nil)
250
-
if err != nil {
251
-
return nil, err
252
-
}
253
-
254
-
return us.client.Do(req)
255
-
}
256
-
257
-
func (us *UnsignedClient) Branches(ownerDid, repoName string) (*http.Response, error) {
258
-
const (
259
-
Method = "GET"
260
-
)
261
-
262
-
endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName)
263
-
264
-
req, err := us.newRequest(Method, endpoint, nil)
265
-
if err != nil {
266
-
return nil, err
267
-
}
268
-
269
-
return us.client.Do(req)
270
-
}
···
+15
-9
appview/state/star.go
+15
-9
appview/state/star.go
···
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
-
tangled "tangled.sh/tangled.sh/core/api/tangled"
12
"tangled.sh/tangled.sh/core/appview/db"
13
"tangled.sh/tangled.sh/core/appview/pages"
14
)
15
16
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
17
-
currentUser := s.auth.GetUser(r)
18
19
subject := r.URL.Query().Get("subject")
20
if subject == "" {
···
28
return
29
}
30
31
-
client, _ := s.auth.AuthorizedClient(r)
32
33
switch r.Method {
34
case http.MethodPost:
35
createdAt := time.Now().Format(time.RFC3339)
36
-
rkey := s.TID()
37
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
38
Collection: tangled.FeedStarNSID,
39
Repo: currentUser.Did,
40
Rkey: rkey,
···
62
63
log.Println("created atproto record: ", resp.Uri)
64
65
-
s.pages.StarFragment(w, pages.StarFragmentParams{
66
IsStarred: true,
67
RepoAt: subjectUri,
68
Stats: db.RepoStats{
···
79
return
80
}
81
82
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
83
Collection: tangled.FeedStarNSID,
84
Repo: currentUser.Did,
85
Rkey: star.Rkey,
···
90
return
91
}
92
93
-
err = db.DeleteStar(s.db, currentUser.Did, subjectUri)
94
if err != nil {
95
log.Println("failed to delete star from DB")
96
// this is not an issue, the firehose event might have already done this
···
99
starCount, err := db.GetStarCount(s.db, subjectUri)
100
if err != nil {
101
log.Println("failed to get star count for ", subjectUri)
102
}
103
104
-
s.pages.StarFragment(w, pages.StarFragmentParams{
105
IsStarred: false,
106
RepoAt: subjectUri,
107
Stats: db.RepoStats{
···
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
+
"tangled.sh/tangled.sh/core/api/tangled"
12
+
"tangled.sh/tangled.sh/core/appview"
13
"tangled.sh/tangled.sh/core/appview/db"
14
"tangled.sh/tangled.sh/core/appview/pages"
15
)
16
17
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
18
+
currentUser := s.oauth.GetUser(r)
19
20
subject := r.URL.Query().Get("subject")
21
if subject == "" {
···
29
return
30
}
31
32
+
client, err := s.oauth.AuthorizedClient(r)
33
+
if err != nil {
34
+
log.Println("failed to authorize client", err)
35
+
return
36
+
}
37
38
switch r.Method {
39
case http.MethodPost:
40
createdAt := time.Now().Format(time.RFC3339)
41
+
rkey := appview.TID()
42
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
43
Collection: tangled.FeedStarNSID,
44
Repo: currentUser.Did,
45
Rkey: rkey,
···
67
68
log.Println("created atproto record: ", resp.Uri)
69
70
+
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
71
IsStarred: true,
72
RepoAt: subjectUri,
73
Stats: db.RepoStats{
···
84
return
85
}
86
87
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
88
Collection: tangled.FeedStarNSID,
89
Repo: currentUser.Did,
90
Rkey: star.Rkey,
···
95
return
96
}
97
98
+
err = db.DeleteStarByRkey(s.db, currentUser.Did, star.Rkey)
99
if err != nil {
100
log.Println("failed to delete star from DB")
101
// this is not an issue, the firehose event might have already done this
···
104
starCount, err := db.GetStarCount(s.db, subjectUri)
105
if err != nil {
106
log.Println("failed to get star count for ", subjectUri)
107
+
return
108
}
109
110
+
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
111
IsStarred: false,
112
RepoAt: subjectUri,
113
Stats: db.RepoStats{
+191
-199
appview/state/state.go
+191
-199
appview/state/state.go
···
17
lexutil "github.com/bluesky-social/indigo/lex/util"
18
securejoin "github.com/cyphar/filepath-securejoin"
19
"github.com/go-chi/chi/v5"
20
-
tangled "tangled.sh/tangled.sh/core/api/tangled"
21
"tangled.sh/tangled.sh/core/appview"
22
-
"tangled.sh/tangled.sh/core/appview/auth"
23
"tangled.sh/tangled.sh/core/appview/db"
24
"tangled.sh/tangled.sh/core/appview/pages"
25
"tangled.sh/tangled.sh/core/jetstream"
26
"tangled.sh/tangled.sh/core/rbac"
···
28
29
type State struct {
30
db *db.DB
31
-
auth *auth.Auth
32
enforcer *rbac.Enforcer
33
-
tidClock *syntax.TIDClock
34
pages *pages.Pages
35
resolver *appview.Resolver
36
jc *jetstream.JetstreamClient
···
38
}
39
40
func Make(config *appview.Config) (*State, error) {
41
-
d, err := db.Make(config.DbPath)
42
if err != nil {
43
return nil, err
44
}
45
46
-
auth, err := auth.Make(config.CookieSecret)
47
-
if err != nil {
48
-
return nil, err
49
-
}
50
-
51
-
enforcer, err := rbac.NewEnforcer(config.DbPath)
52
if err != nil {
53
return nil, err
54
}
55
56
clock := syntax.NewTIDClock(0)
57
58
-
pgs := pages.NewPages()
59
60
resolver := appview.NewResolver()
61
62
wrapper := db.DbWrapper{d}
63
-
jc, err := jetstream.NewJetstreamClient(config.JetstreamEndpoint, "appview", []string{tangled.GraphFollowNSID}, nil, slog.Default(), wrapper, false)
64
if err != nil {
65
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
66
}
67
-
err = jc.StartJetstream(context.Background(), jetstreamIngester(wrapper))
68
if err != nil {
69
return nil, fmt.Errorf("failed to start jetstream watcher: %w", err)
70
}
71
72
state := &State{
73
d,
74
-
auth,
75
enforcer,
76
clock,
77
pgs,
···
83
return state, nil
84
}
85
86
-
func (s *State) TID() string {
87
-
return s.tidClock.Next().String()
88
}
89
90
-
func (s *State) Login(w http.ResponseWriter, r *http.Request) {
91
-
ctx := r.Context()
92
93
-
switch r.Method {
94
-
case http.MethodGet:
95
-
err := s.pages.Login(w, pages.LoginParams{})
96
-
if err != nil {
97
-
log.Printf("rendering login page: %s", err)
98
-
}
99
100
-
return
101
-
case http.MethodPost:
102
-
handle := strings.TrimPrefix(r.FormValue("handle"), "@")
103
-
appPassword := r.FormValue("app_password")
104
105
-
resolved, err := s.resolver.ResolveIdent(ctx, handle)
106
-
if err != nil {
107
-
log.Println("failed to resolve handle:", err)
108
-
s.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle))
109
-
return
110
-
}
111
112
-
atSession, err := s.auth.CreateInitialSession(ctx, resolved, appPassword)
113
-
if err != nil {
114
-
s.pages.Notice(w, "login-msg", "Invalid handle or password.")
115
-
return
116
-
}
117
-
sessionish := auth.CreateSessionWrapper{ServerCreateSession_Output: atSession}
118
119
-
err = s.auth.StoreSession(r, w, &sessionish, resolved.PDSEndpoint())
120
-
if err != nil {
121
-
s.pages.Notice(w, "login-msg", "Failed to login, try again later.")
122
-
return
123
-
}
124
125
-
log.Printf("successfully saved session for %s (%s)", atSession.Handle, atSession.Did)
126
127
-
did := resolved.DID.String()
128
-
defaultKnot := "knot1.tangled.sh"
129
130
-
go func() {
131
-
log.Printf("adding %s to default knot", did)
132
-
err = s.enforcer.AddMember(defaultKnot, did)
133
-
if err != nil {
134
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
135
-
return
136
-
}
137
-
err = s.enforcer.E.SavePolicy()
138
-
if err != nil {
139
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
140
-
return
141
-
}
142
143
-
secret, err := db.GetRegistrationKey(s.db, defaultKnot)
144
-
if err != nil {
145
-
log.Println("failed to get registration key for knot1.tangled.sh")
146
-
return
147
-
}
148
-
signedClient, err := NewSignedClient(defaultKnot, secret, s.config.Dev)
149
-
resp, err := signedClient.AddMember(did)
150
-
if err != nil {
151
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
152
-
return
153
-
}
154
155
-
if resp.StatusCode != http.StatusNoContent {
156
-
log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode)
157
-
return
158
-
}
159
-
}()
160
161
-
s.pages.HxRedirect(w, "/")
162
-
return
163
-
}
164
-
}
165
166
func (s *State) Logout(w http.ResponseWriter, r *http.Request) {
167
-
s.auth.ClearSession(r, w)
168
-
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
169
}
170
171
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
172
-
user := s.auth.GetUser(r)
173
174
timeline, err := db.MakeTimeline(s.db)
175
if err != nil {
···
181
for _, ev := range timeline {
182
if ev.Repo != nil {
183
didsToResolve = append(didsToResolve, ev.Repo.Did)
184
}
185
if ev.Follow != nil {
186
didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
···
217
218
return
219
case http.MethodPost:
220
-
session, err := s.auth.Store.Get(r, appview.SessionName)
221
if err != nil || session.IsNew {
222
log.Println("unauthorized attempt to generate registration key")
223
http.Error(w, "Forbidden", http.StatusUnauthorized)
···
279
280
// create a signed request and check if a node responds to that
281
func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) {
282
-
user := s.auth.GetUser(r)
283
284
domain := chi.URLParam(r, "domain")
285
if domain == "" {
···
294
return
295
}
296
297
-
client, err := NewSignedClient(domain, secret, s.config.Dev)
298
if err != nil {
299
log.Println("failed to create client to ", domain)
300
}
···
403
return
404
}
405
406
-
user := s.auth.GetUser(r)
407
reg, err := db.RegistrationByDomain(s.db, domain)
408
if err != nil {
409
w.Write([]byte("failed to pull up registration info"))
···
419
}
420
}
421
422
ok, err := s.enforcer.IsServerOwner(user.Did, domain)
423
isOwner := err == nil && ok
424
425
p := pages.KnotParams{
426
LoggedInUser: user,
427
Registration: reg,
428
Members: members,
429
IsOwner: isOwner,
···
435
// get knots registered by this user
436
func (s *State) Knots(w http.ResponseWriter, r *http.Request) {
437
// for now, this is just pubkeys
438
-
user := s.auth.GetUser(r)
439
registrations, err := db.RegistrationsByDid(s.db, user.Did)
440
if err != nil {
441
log.Println(err)
···
474
return
475
}
476
477
-
memberDid := r.FormValue("member")
478
-
if memberDid == "" {
479
http.Error(w, "malformed form", http.StatusBadRequest)
480
return
481
}
482
483
-
memberIdent, err := s.resolver.ResolveIdent(r.Context(), memberDid)
484
if err != nil {
485
w.Write([]byte("failed to resolve member did to a handle"))
486
return
487
}
488
-
log.Printf("adding %s to %s\n", memberIdent.Handle.String(), domain)
489
490
// announce this relation into the firehose, store into owners' pds
491
-
client, _ := s.auth.AuthorizedClient(r)
492
-
currentUser := s.auth.GetUser(r)
493
-
addedAt := time.Now().Format(time.RFC3339)
494
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
495
Collection: tangled.KnotMemberNSID,
496
Repo: currentUser.Did,
497
-
Rkey: s.TID(),
498
Record: &lexutil.LexiconTypeDecoder{
499
Val: &tangled.KnotMember{
500
-
Member: memberIdent.DID.String(),
501
-
Domain: domain,
502
-
AddedAt: &addedAt,
503
}},
504
})
505
···
516
return
517
}
518
519
-
ksClient, err := NewSignedClient(domain, secret, s.config.Dev)
520
if err != nil {
521
log.Println("failed to create client to ", domain)
522
return
523
}
524
525
-
ksResp, err := ksClient.AddMember(memberIdent.DID.String())
526
if err != nil {
527
log.Printf("failed to make request to %s: %s", domain, err)
528
return
···
533
return
534
}
535
536
-
err = s.enforcer.AddMember(domain, memberIdent.DID.String())
537
if err != nil {
538
w.Write([]byte(fmt.Sprint("failed to add member: ", err)))
539
return
540
}
541
542
-
w.Write([]byte(fmt.Sprint("added member: ", memberIdent.Handle.String())))
543
}
544
545
func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) {
546
}
547
548
func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
549
switch r.Method {
550
case http.MethodGet:
551
-
user := s.auth.GetUser(r)
552
knots, err := s.enforcer.GetDomainsForUser(user.Did)
553
if err != nil {
554
s.pages.Notice(w, "repo", "Invalid user account.")
···
561
})
562
563
case http.MethodPost:
564
-
user := s.auth.GetUser(r)
565
566
domain := r.FormValue("domain")
567
if domain == "" {
···
575
return
576
}
577
578
-
// Check for valid repository name (GitHub-like rules)
579
-
// No spaces, only alphanumeric characters, dashes, and underscores
580
-
for _, char := range repoName {
581
-
if !((char >= 'a' && char <= 'z') ||
582
-
(char >= 'A' && char <= 'Z') ||
583
-
(char >= '0' && char <= '9') ||
584
-
char == '-' || char == '_' || char == '.') {
585
-
s.pages.Notice(w, "repo", "Repository name can only contain alphanumeric characters, periods, hyphens, and underscores.")
586
-
return
587
-
}
588
}
589
590
defaultBranch := r.FormValue("branch")
···
612
return
613
}
614
615
-
client, err := NewSignedClient(domain, secret, s.config.Dev)
616
if err != nil {
617
s.pages.Notice(w, "repo", "Failed to connect to knot server.")
618
return
619
}
620
621
-
rkey := s.TID()
622
repo := &db.Repo{
623
Did: user.Did,
624
Name: repoName,
···
627
Description: description,
628
}
629
630
-
xrpcClient, _ := s.auth.AuthorizedClient(r)
631
632
-
addedAt := time.Now().Format(time.RFC3339)
633
-
atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
634
Collection: tangled.RepoNSID,
635
Repo: user.Did,
636
Rkey: rkey,
637
Record: &lexutil.LexiconTypeDecoder{
638
Val: &tangled.Repo{
639
-
Knot: repo.Knot,
640
-
Name: repoName,
641
-
AddedAt: &addedAt,
642
-
Owner: user.Did,
643
}},
644
})
645
if err != nil {
···
714
return
715
}
716
}
717
-
718
-
func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) {
719
-
didOrHandle := chi.URLParam(r, "user")
720
-
if didOrHandle == "" {
721
-
http.Error(w, "Bad request", http.StatusBadRequest)
722
-
return
723
-
}
724
-
725
-
ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle)
726
-
if err != nil {
727
-
log.Printf("resolving identity: %s", err)
728
-
w.WriteHeader(http.StatusNotFound)
729
-
return
730
-
}
731
-
732
-
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
733
-
if err != nil {
734
-
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
735
-
}
736
-
737
-
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
738
-
if err != nil {
739
-
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
740
-
}
741
-
var didsToResolve []string
742
-
for _, r := range collaboratingRepos {
743
-
didsToResolve = append(didsToResolve, r.Did)
744
-
}
745
-
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
746
-
didHandleMap := make(map[string]string)
747
-
for _, identity := range resolvedIds {
748
-
if !identity.Handle.IsInvalidHandle() {
749
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
750
-
} else {
751
-
didHandleMap[identity.DID.String()] = identity.DID.String()
752
-
}
753
-
}
754
-
755
-
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
756
-
if err != nil {
757
-
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
758
-
}
759
-
760
-
loggedInUser := s.auth.GetUser(r)
761
-
followStatus := db.IsNotFollowing
762
-
if loggedInUser != nil {
763
-
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
764
-
}
765
-
766
-
profileAvatarUri, err := GetAvatarUri(ident.Handle.String())
767
-
if err != nil {
768
-
log.Println("failed to fetch bsky avatar", err)
769
-
}
770
-
771
-
s.pages.ProfilePage(w, pages.ProfilePageParams{
772
-
LoggedInUser: loggedInUser,
773
-
UserDid: ident.DID.String(),
774
-
UserHandle: ident.Handle.String(),
775
-
Repos: repos,
776
-
CollaboratingRepos: collaboratingRepos,
777
-
ProfileStats: pages.ProfileStats{
778
-
Followers: followers,
779
-
Following: following,
780
-
},
781
-
FollowStatus: db.FollowStatus(followStatus),
782
-
DidHandleMap: didHandleMap,
783
-
AvatarUri: profileAvatarUri,
784
-
})
785
-
}
786
-
787
-
func GetAvatarUri(handle string) (string, error) {
788
-
return fmt.Sprintf("https://avatars.dog/%s@webp", handle), nil
789
-
}
···
17
lexutil "github.com/bluesky-social/indigo/lex/util"
18
securejoin "github.com/cyphar/filepath-securejoin"
19
"github.com/go-chi/chi/v5"
20
+
"tangled.sh/tangled.sh/core/api/tangled"
21
"tangled.sh/tangled.sh/core/appview"
22
"tangled.sh/tangled.sh/core/appview/db"
23
+
"tangled.sh/tangled.sh/core/appview/knotclient"
24
+
"tangled.sh/tangled.sh/core/appview/oauth"
25
"tangled.sh/tangled.sh/core/appview/pages"
26
"tangled.sh/tangled.sh/core/jetstream"
27
"tangled.sh/tangled.sh/core/rbac"
···
29
30
type State struct {
31
db *db.DB
32
+
oauth *oauth.OAuth
33
enforcer *rbac.Enforcer
34
+
tidClock syntax.TIDClock
35
pages *pages.Pages
36
resolver *appview.Resolver
37
jc *jetstream.JetstreamClient
···
39
}
40
41
func Make(config *appview.Config) (*State, error) {
42
+
d, err := db.Make(config.Core.DbPath)
43
if err != nil {
44
return nil, err
45
}
46
47
+
enforcer, err := rbac.NewEnforcer(config.Core.DbPath)
48
if err != nil {
49
return nil, err
50
}
51
52
clock := syntax.NewTIDClock(0)
53
54
+
pgs := pages.NewPages(config)
55
56
resolver := appview.NewResolver()
57
+
58
+
oauth := oauth.NewOAuth(d, config)
59
60
wrapper := db.DbWrapper{d}
61
+
jc, err := jetstream.NewJetstreamClient(
62
+
config.Jetstream.Endpoint,
63
+
"appview",
64
+
[]string{
65
+
tangled.GraphFollowNSID,
66
+
tangled.FeedStarNSID,
67
+
tangled.PublicKeyNSID,
68
+
tangled.RepoArtifactNSID,
69
+
tangled.ActorProfileNSID,
70
+
},
71
+
nil,
72
+
slog.Default(),
73
+
wrapper,
74
+
false,
75
+
)
76
if err != nil {
77
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
78
}
79
+
err = jc.StartJetstream(context.Background(), appview.Ingest(wrapper, enforcer))
80
if err != nil {
81
return nil, fmt.Errorf("failed to start jetstream watcher: %w", err)
82
}
83
84
state := &State{
85
d,
86
+
oauth,
87
enforcer,
88
clock,
89
pgs,
···
95
return state, nil
96
}
97
98
+
func TID(c *syntax.TIDClock) string {
99
+
return c.Next().String()
100
}
101
102
+
// func (s *State) Login(w http.ResponseWriter, r *http.Request) {
103
+
// ctx := r.Context()
104
105
+
// switch r.Method {
106
+
// case http.MethodGet:
107
+
// err := s.pages.Login(w, pages.LoginParams{})
108
+
// if err != nil {
109
+
// log.Printf("rendering login page: %s", err)
110
+
// }
111
112
+
// return
113
+
// case http.MethodPost:
114
+
// handle := strings.TrimPrefix(r.FormValue("handle"), "@")
115
+
// appPassword := r.FormValue("app_password")
116
117
+
// resolved, err := s.resolver.ResolveIdent(ctx, handle)
118
+
// if err != nil {
119
+
// log.Println("failed to resolve handle:", err)
120
+
// s.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle))
121
+
// return
122
+
// }
123
124
+
// atSession, err := s.oauth.CreateInitialSession(ctx, resolved, appPassword)
125
+
// if err != nil {
126
+
// s.pages.Notice(w, "login-msg", "Invalid handle or password.")
127
+
// return
128
+
// }
129
+
// sessionish := auth.CreateSessionWrapper{ServerCreateSession_Output: atSession}
130
131
+
// err = s.oauth.StoreSession(r, w, &sessionish, resolved.PDSEndpoint())
132
+
// if err != nil {
133
+
// s.pages.Notice(w, "login-msg", "Failed to login, try again later.")
134
+
// return
135
+
// }
136
137
+
// log.Printf("successfully saved session for %s (%s)", atSession.Handle, atSession.Did)
138
139
+
// did := resolved.DID.String()
140
+
// defaultKnot := "knot1.tangled.sh"
141
142
+
// go func() {
143
+
// log.Printf("adding %s to default knot", did)
144
+
// err = s.enforcer.AddMember(defaultKnot, did)
145
+
// if err != nil {
146
+
// log.Println("failed to add user to knot1.tangled.sh: ", err)
147
+
// return
148
+
// }
149
+
// err = s.enforcer.E.SavePolicy()
150
+
// if err != nil {
151
+
// log.Println("failed to add user to knot1.tangled.sh: ", err)
152
+
// return
153
+
// }
154
155
+
// secret, err := db.GetRegistrationKey(s.db, defaultKnot)
156
+
// if err != nil {
157
+
// log.Println("failed to get registration key for knot1.tangled.sh")
158
+
// return
159
+
// }
160
+
// signedClient, err := NewSignedClient(defaultKnot, secret, s.config.Core.Dev)
161
+
// resp, err := signedClient.AddMember(did)
162
+
// if err != nil {
163
+
// log.Println("failed to add user to knot1.tangled.sh: ", err)
164
+
// return
165
+
// }
166
167
+
// if resp.StatusCode != http.StatusNoContent {
168
+
// log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode)
169
+
// return
170
+
// }
171
+
// }()
172
173
+
// s.pages.HxRedirect(w, "/")
174
+
// return
175
+
// }
176
+
// }
177
178
func (s *State) Logout(w http.ResponseWriter, r *http.Request) {
179
+
s.oauth.ClearSession(r, w)
180
+
w.Header().Set("HX-Redirect", "/login")
181
+
w.WriteHeader(http.StatusSeeOther)
182
}
183
184
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
185
+
user := s.oauth.GetUser(r)
186
187
timeline, err := db.MakeTimeline(s.db)
188
if err != nil {
···
194
for _, ev := range timeline {
195
if ev.Repo != nil {
196
didsToResolve = append(didsToResolve, ev.Repo.Did)
197
+
if ev.Source != nil {
198
+
didsToResolve = append(didsToResolve, ev.Source.Did)
199
+
}
200
}
201
if ev.Follow != nil {
202
didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
···
233
234
return
235
case http.MethodPost:
236
+
session, err := s.oauth.Store.Get(r, appview.SessionName)
237
if err != nil || session.IsNew {
238
log.Println("unauthorized attempt to generate registration key")
239
http.Error(w, "Forbidden", http.StatusUnauthorized)
···
295
296
// create a signed request and check if a node responds to that
297
func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) {
298
+
user := s.oauth.GetUser(r)
299
300
domain := chi.URLParam(r, "domain")
301
if domain == "" {
···
310
return
311
}
312
313
+
client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
314
if err != nil {
315
log.Println("failed to create client to ", domain)
316
}
···
419
return
420
}
421
422
+
user := s.oauth.GetUser(r)
423
reg, err := db.RegistrationByDomain(s.db, domain)
424
if err != nil {
425
w.Write([]byte("failed to pull up registration info"))
···
435
}
436
}
437
438
+
var didsToResolve []string
439
+
for _, m := range members {
440
+
didsToResolve = append(didsToResolve, m)
441
+
}
442
+
didsToResolve = append(didsToResolve, reg.ByDid)
443
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
444
+
didHandleMap := make(map[string]string)
445
+
for _, identity := range resolvedIds {
446
+
if !identity.Handle.IsInvalidHandle() {
447
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
448
+
} else {
449
+
didHandleMap[identity.DID.String()] = identity.DID.String()
450
+
}
451
+
}
452
+
453
ok, err := s.enforcer.IsServerOwner(user.Did, domain)
454
isOwner := err == nil && ok
455
456
p := pages.KnotParams{
457
LoggedInUser: user,
458
+
DidHandleMap: didHandleMap,
459
Registration: reg,
460
Members: members,
461
IsOwner: isOwner,
···
467
// get knots registered by this user
468
func (s *State) Knots(w http.ResponseWriter, r *http.Request) {
469
// for now, this is just pubkeys
470
+
user := s.oauth.GetUser(r)
471
registrations, err := db.RegistrationsByDid(s.db, user.Did)
472
if err != nil {
473
log.Println(err)
···
506
return
507
}
508
509
+
subjectIdentifier := r.FormValue("subject")
510
+
if subjectIdentifier == "" {
511
http.Error(w, "malformed form", http.StatusBadRequest)
512
return
513
}
514
515
+
subjectIdentity, err := s.resolver.ResolveIdent(r.Context(), subjectIdentifier)
516
if err != nil {
517
w.Write([]byte("failed to resolve member did to a handle"))
518
return
519
}
520
+
log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain)
521
522
// announce this relation into the firehose, store into owners' pds
523
+
client, err := s.oauth.AuthorizedClient(r)
524
+
if err != nil {
525
+
http.Error(w, "failed to authorize client", http.StatusInternalServerError)
526
+
return
527
+
}
528
+
currentUser := s.oauth.GetUser(r)
529
+
createdAt := time.Now().Format(time.RFC3339)
530
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
531
Collection: tangled.KnotMemberNSID,
532
Repo: currentUser.Did,
533
+
Rkey: appview.TID(),
534
Record: &lexutil.LexiconTypeDecoder{
535
Val: &tangled.KnotMember{
536
+
Subject: subjectIdentity.DID.String(),
537
+
Domain: domain,
538
+
CreatedAt: createdAt,
539
}},
540
})
541
···
552
return
553
}
554
555
+
ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
556
if err != nil {
557
log.Println("failed to create client to ", domain)
558
return
559
}
560
561
+
ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
562
if err != nil {
563
log.Printf("failed to make request to %s: %s", domain, err)
564
return
···
569
return
570
}
571
572
+
err = s.enforcer.AddMember(domain, subjectIdentity.DID.String())
573
if err != nil {
574
w.Write([]byte(fmt.Sprint("failed to add member: ", err)))
575
return
576
}
577
578
+
w.Write([]byte(fmt.Sprint("added member: ", subjectIdentity.Handle.String())))
579
}
580
581
func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) {
582
}
583
584
+
func validateRepoName(name string) error {
585
+
// check for path traversal attempts
586
+
if name == "." || name == ".." ||
587
+
strings.Contains(name, "/") || strings.Contains(name, "\\") {
588
+
return fmt.Errorf("Repository name contains invalid path characters")
589
+
}
590
+
591
+
// check for sequences that could be used for traversal when normalized
592
+
if strings.Contains(name, "./") || strings.Contains(name, "../") ||
593
+
strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
594
+
return fmt.Errorf("Repository name contains invalid path sequence")
595
+
}
596
+
597
+
// then continue with character validation
598
+
for _, char := range name {
599
+
if !((char >= 'a' && char <= 'z') ||
600
+
(char >= 'A' && char <= 'Z') ||
601
+
(char >= '0' && char <= '9') ||
602
+
char == '-' || char == '_' || char == '.') {
603
+
return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
604
+
}
605
+
}
606
+
607
+
// additional check to prevent multiple sequential dots
608
+
if strings.Contains(name, "..") {
609
+
return fmt.Errorf("Repository name cannot contain sequential dots")
610
+
}
611
+
612
+
// if all checks pass
613
+
return nil
614
+
}
615
+
616
func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
617
switch r.Method {
618
case http.MethodGet:
619
+
user := s.oauth.GetUser(r)
620
knots, err := s.enforcer.GetDomainsForUser(user.Did)
621
if err != nil {
622
s.pages.Notice(w, "repo", "Invalid user account.")
···
629
})
630
631
case http.MethodPost:
632
+
user := s.oauth.GetUser(r)
633
634
domain := r.FormValue("domain")
635
if domain == "" {
···
643
return
644
}
645
646
+
if err := validateRepoName(repoName); err != nil {
647
+
s.pages.Notice(w, "repo", err.Error())
648
+
return
649
}
650
651
defaultBranch := r.FormValue("branch")
···
673
return
674
}
675
676
+
client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
677
if err != nil {
678
s.pages.Notice(w, "repo", "Failed to connect to knot server.")
679
return
680
}
681
682
+
rkey := appview.TID()
683
repo := &db.Repo{
684
Did: user.Did,
685
Name: repoName,
···
688
Description: description,
689
}
690
691
+
xrpcClient, err := s.oauth.AuthorizedClient(r)
692
+
if err != nil {
693
+
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
694
+
return
695
+
}
696
697
+
createdAt := time.Now().Format(time.RFC3339)
698
+
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
699
Collection: tangled.RepoNSID,
700
Repo: user.Did,
701
Rkey: rkey,
702
Record: &lexutil.LexiconTypeDecoder{
703
Val: &tangled.Repo{
704
+
Knot: repo.Knot,
705
+
Name: repoName,
706
+
CreatedAt: createdAt,
707
+
Owner: user.Did,
708
}},
709
})
710
if err != nil {
···
779
return
780
}
781
}
+11
appview/tid.go
+11
appview/tid.go
+80
appview/xrpcclient/xrpc.go
+80
appview/xrpcclient/xrpc.go
···
···
1
+
package xrpcclient
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"io"
7
+
8
+
"github.com/bluesky-social/indigo/api/atproto"
9
+
"github.com/bluesky-social/indigo/xrpc"
10
+
oauth "github.com/haileyok/atproto-oauth-golang"
11
+
)
12
+
13
+
type Client struct {
14
+
*oauth.XrpcClient
15
+
authArgs *oauth.XrpcAuthedRequestArgs
16
+
}
17
+
18
+
func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client {
19
+
return &Client{
20
+
XrpcClient: client,
21
+
authArgs: authArgs,
22
+
}
23
+
}
24
+
25
+
func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) {
26
+
var out atproto.RepoPutRecord_Output
27
+
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil {
28
+
return nil, err
29
+
}
30
+
31
+
return &out, nil
32
+
}
33
+
34
+
func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) {
35
+
var out atproto.RepoGetRecord_Output
36
+
37
+
params := map[string]interface{}{
38
+
"cid": cid,
39
+
"collection": collection,
40
+
"repo": repo,
41
+
"rkey": rkey,
42
+
}
43
+
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil {
44
+
return nil, err
45
+
}
46
+
47
+
return &out, nil
48
+
}
49
+
50
+
func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) {
51
+
var out atproto.RepoUploadBlob_Output
52
+
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil {
53
+
return nil, err
54
+
}
55
+
56
+
return &out, nil
57
+
}
58
+
59
+
func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) {
60
+
buf := new(bytes.Buffer)
61
+
62
+
params := map[string]interface{}{
63
+
"cid": cid,
64
+
"did": did,
65
+
}
66
+
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil {
67
+
return nil, err
68
+
}
69
+
70
+
return buf.Bytes(), nil
71
+
}
72
+
73
+
func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) {
74
+
var out atproto.RepoDeleteRecord_Output
75
+
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil {
76
+
return nil, err
77
+
}
78
+
79
+
return &out, nil
80
+
}
+172
avatar/.gitignore
+172
avatar/.gitignore
···
···
1
+
# Logs
2
+
3
+
logs
4
+
_.log
5
+
npm-debug.log_
6
+
yarn-debug.log*
7
+
yarn-error.log*
8
+
lerna-debug.log*
9
+
.pnpm-debug.log*
10
+
11
+
# Diagnostic reports (https://nodejs.org/api/report.html)
12
+
13
+
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
14
+
15
+
# Runtime data
16
+
17
+
pids
18
+
_.pid
19
+
_.seed
20
+
\*.pid.lock
21
+
22
+
# Directory for instrumented libs generated by jscoverage/JSCover
23
+
24
+
lib-cov
25
+
26
+
# Coverage directory used by tools like istanbul
27
+
28
+
coverage
29
+
\*.lcov
30
+
31
+
# nyc test coverage
32
+
33
+
.nyc_output
34
+
35
+
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
36
+
37
+
.grunt
38
+
39
+
# Bower dependency directory (https://bower.io/)
40
+
41
+
bower_components
42
+
43
+
# node-waf configuration
44
+
45
+
.lock-wscript
46
+
47
+
# Compiled binary addons (https://nodejs.org/api/addons.html)
48
+
49
+
build/Release
50
+
51
+
# Dependency directories
52
+
53
+
node_modules/
54
+
jspm_packages/
55
+
56
+
# Snowpack dependency directory (https://snowpack.dev/)
57
+
58
+
web_modules/
59
+
60
+
# TypeScript cache
61
+
62
+
\*.tsbuildinfo
63
+
64
+
# Optional npm cache directory
65
+
66
+
.npm
67
+
68
+
# Optional eslint cache
69
+
70
+
.eslintcache
71
+
72
+
# Optional stylelint cache
73
+
74
+
.stylelintcache
75
+
76
+
# Microbundle cache
77
+
78
+
.rpt2_cache/
79
+
.rts2_cache_cjs/
80
+
.rts2_cache_es/
81
+
.rts2_cache_umd/
82
+
83
+
# Optional REPL history
84
+
85
+
.node_repl_history
86
+
87
+
# Output of 'npm pack'
88
+
89
+
\*.tgz
90
+
91
+
# Yarn Integrity file
92
+
93
+
.yarn-integrity
94
+
95
+
# dotenv environment variable files
96
+
97
+
.env
98
+
.env.development.local
99
+
.env.test.local
100
+
.env.production.local
101
+
.env.local
102
+
103
+
# parcel-bundler cache (https://parceljs.org/)
104
+
105
+
.cache
106
+
.parcel-cache
107
+
108
+
# Next.js build output
109
+
110
+
.next
111
+
out
112
+
113
+
# Nuxt.js build / generate output
114
+
115
+
.nuxt
116
+
dist
117
+
118
+
# Gatsby files
119
+
120
+
.cache/
121
+
122
+
# Comment in the public line in if your project uses Gatsby and not Next.js
123
+
124
+
# https://nextjs.org/blog/next-9-1#public-directory-support
125
+
126
+
# public
127
+
128
+
# vuepress build output
129
+
130
+
.vuepress/dist
131
+
132
+
# vuepress v2.x temp and cache directory
133
+
134
+
.temp
135
+
.cache
136
+
137
+
# Docusaurus cache and generated files
138
+
139
+
.docusaurus
140
+
141
+
# Serverless directories
142
+
143
+
.serverless/
144
+
145
+
# FuseBox cache
146
+
147
+
.fusebox/
148
+
149
+
# DynamoDB Local files
150
+
151
+
.dynamodb/
152
+
153
+
# TernJS port file
154
+
155
+
.tern-port
156
+
157
+
# Stores VSCode versions used for testing VSCode extensions
158
+
159
+
.vscode-test
160
+
161
+
# yarn v2
162
+
163
+
.yarn/cache
164
+
.yarn/unplugged
165
+
.yarn/build-state.yml
166
+
.yarn/install-state.gz
167
+
.pnp.\*
168
+
169
+
# wrangler project
170
+
171
+
.dev.vars
172
+
.wrangler/
+3024
avatar/package-lock.json
+3024
avatar/package-lock.json
···
···
1
+
{
2
+
"name": "avatar",
3
+
"version": "0.0.0",
4
+
"lockfileVersion": 3,
5
+
"requires": true,
6
+
"packages": {
7
+
"": {
8
+
"name": "avatar",
9
+
"version": "0.0.0",
10
+
"devDependencies": {
11
+
"@cloudflare/vitest-pool-workers": "^0.8.19",
12
+
"vitest": "~3.0.7",
13
+
"wrangler": "^4.14.1"
14
+
}
15
+
},
16
+
"node_modules/@cloudflare/kv-asset-handler": {
17
+
"version": "0.4.0",
18
+
"resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz",
19
+
"integrity": "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==",
20
+
"dev": true,
21
+
"license": "MIT OR Apache-2.0",
22
+
"dependencies": {
23
+
"mime": "^3.0.0"
24
+
},
25
+
"engines": {
26
+
"node": ">=18.0.0"
27
+
}
28
+
},
29
+
"node_modules/@cloudflare/unenv-preset": {
30
+
"version": "2.3.1",
31
+
"resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.3.1.tgz",
32
+
"integrity": "sha512-Xq57Qd+ADpt6hibcVBO0uLG9zzRgyRhfCUgBT9s+g3+3Ivg5zDyVgLFy40ES1VdNcu8rPNSivm9A+kGP5IVaPg==",
33
+
"dev": true,
34
+
"license": "MIT OR Apache-2.0",
35
+
"peerDependencies": {
36
+
"unenv": "2.0.0-rc.15",
37
+
"workerd": "^1.20250320.0"
38
+
},
39
+
"peerDependenciesMeta": {
40
+
"workerd": {
41
+
"optional": true
42
+
}
43
+
}
44
+
},
45
+
"node_modules/@cloudflare/vitest-pool-workers": {
46
+
"version": "0.8.24",
47
+
"resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.8.24.tgz",
48
+
"integrity": "sha512-wT2PABJQ9YLYWrVu4CRZOjvmjHkdbMyLTZPU9n/7JEMM3pgG8dY41F1Rj31UsXRQaXX39A/CTPGlk58dcMUysA==",
49
+
"dev": true,
50
+
"license": "MIT",
51
+
"dependencies": {
52
+
"birpc": "0.2.14",
53
+
"cjs-module-lexer": "^1.2.3",
54
+
"devalue": "^4.3.0",
55
+
"miniflare": "4.20250428.1",
56
+
"semver": "^7.7.1",
57
+
"wrangler": "4.14.1",
58
+
"zod": "^3.22.3"
59
+
},
60
+
"peerDependencies": {
61
+
"@vitest/runner": "2.0.x - 3.1.x",
62
+
"@vitest/snapshot": "2.0.x - 3.1.x",
63
+
"vitest": "2.0.x - 3.1.x"
64
+
}
65
+
},
66
+
"node_modules/@cloudflare/workerd-darwin-64": {
67
+
"version": "1.20250428.0",
68
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250428.0.tgz",
69
+
"integrity": "sha512-6nVe9oV4Hdec6ctzMtW80TiDvNTd2oFPi3VsKqSDVaJSJbL+4b6seyJ7G/UEPI+si6JhHBSLV2/9lNXNGLjClA==",
70
+
"cpu": [
71
+
"x64"
72
+
],
73
+
"dev": true,
74
+
"license": "Apache-2.0",
75
+
"optional": true,
76
+
"os": [
77
+
"darwin"
78
+
],
79
+
"engines": {
80
+
"node": ">=16"
81
+
}
82
+
},
83
+
"node_modules/@cloudflare/workerd-darwin-arm64": {
84
+
"version": "1.20250428.0",
85
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250428.0.tgz",
86
+
"integrity": "sha512-/TB7bh7SIJ5f+6r4PHsAz7+9Qal/TK1cJuKFkUno1kqGlZbdrMwH0ATYwlWC/nBFeu2FB3NUolsTntEuy23hnQ==",
87
+
"cpu": [
88
+
"arm64"
89
+
],
90
+
"dev": true,
91
+
"license": "Apache-2.0",
92
+
"optional": true,
93
+
"os": [
94
+
"darwin"
95
+
],
96
+
"engines": {
97
+
"node": ">=16"
98
+
}
99
+
},
100
+
"node_modules/@cloudflare/workerd-linux-64": {
101
+
"version": "1.20250428.0",
102
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250428.0.tgz",
103
+
"integrity": "sha512-9eCbj+R3CKqpiXP6DfAA20DxKge+OTj7Hyw3ZewiEhWH9INIHiJwJQYybu4iq9kJEGjnGvxgguLFjSCWm26hgg==",
104
+
"cpu": [
105
+
"x64"
106
+
],
107
+
"dev": true,
108
+
"license": "Apache-2.0",
109
+
"optional": true,
110
+
"os": [
111
+
"linux"
112
+
],
113
+
"engines": {
114
+
"node": ">=16"
115
+
}
116
+
},
117
+
"node_modules/@cloudflare/workerd-linux-arm64": {
118
+
"version": "1.20250428.0",
119
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250428.0.tgz",
120
+
"integrity": "sha512-D9NRBnW46nl1EQsP13qfkYb5lbt4C6nxl38SBKY/NOcZAUoHzNB5K0GaK8LxvpkM7X/97ySojlMfR5jh5DNXYQ==",
121
+
"cpu": [
122
+
"arm64"
123
+
],
124
+
"dev": true,
125
+
"license": "Apache-2.0",
126
+
"optional": true,
127
+
"os": [
128
+
"linux"
129
+
],
130
+
"engines": {
131
+
"node": ">=16"
132
+
}
133
+
},
134
+
"node_modules/@cloudflare/workerd-windows-64": {
135
+
"version": "1.20250428.0",
136
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250428.0.tgz",
137
+
"integrity": "sha512-RQCRj28eitjKD0tmei6iFOuWqMuHMHdNGEigRmbkmuTlpbWHNAoHikgCzZQ/dkKDdatA76TmcpbyECNf31oaTA==",
138
+
"cpu": [
139
+
"x64"
140
+
],
141
+
"dev": true,
142
+
"license": "Apache-2.0",
143
+
"optional": true,
144
+
"os": [
145
+
"win32"
146
+
],
147
+
"engines": {
148
+
"node": ">=16"
149
+
}
150
+
},
151
+
"node_modules/@cspotcode/source-map-support": {
152
+
"version": "0.8.1",
153
+
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
154
+
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
155
+
"dev": true,
156
+
"license": "MIT",
157
+
"dependencies": {
158
+
"@jridgewell/trace-mapping": "0.3.9"
159
+
},
160
+
"engines": {
161
+
"node": ">=12"
162
+
}
163
+
},
164
+
"node_modules/@emnapi/runtime": {
165
+
"version": "1.4.3",
166
+
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
167
+
"integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
168
+
"dev": true,
169
+
"license": "MIT",
170
+
"optional": true,
171
+
"dependencies": {
172
+
"tslib": "^2.4.0"
173
+
}
174
+
},
175
+
"node_modules/@esbuild/aix-ppc64": {
176
+
"version": "0.25.3",
177
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz",
178
+
"integrity": "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==",
179
+
"cpu": [
180
+
"ppc64"
181
+
],
182
+
"dev": true,
183
+
"license": "MIT",
184
+
"optional": true,
185
+
"os": [
186
+
"aix"
187
+
],
188
+
"engines": {
189
+
"node": ">=18"
190
+
}
191
+
},
192
+
"node_modules/@esbuild/android-arm": {
193
+
"version": "0.25.3",
194
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz",
195
+
"integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==",
196
+
"cpu": [
197
+
"arm"
198
+
],
199
+
"dev": true,
200
+
"license": "MIT",
201
+
"optional": true,
202
+
"os": [
203
+
"android"
204
+
],
205
+
"engines": {
206
+
"node": ">=18"
207
+
}
208
+
},
209
+
"node_modules/@esbuild/android-arm64": {
210
+
"version": "0.25.3",
211
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz",
212
+
"integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==",
213
+
"cpu": [
214
+
"arm64"
215
+
],
216
+
"dev": true,
217
+
"license": "MIT",
218
+
"optional": true,
219
+
"os": [
220
+
"android"
221
+
],
222
+
"engines": {
223
+
"node": ">=18"
224
+
}
225
+
},
226
+
"node_modules/@esbuild/android-x64": {
227
+
"version": "0.25.3",
228
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.3.tgz",
229
+
"integrity": "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==",
230
+
"cpu": [
231
+
"x64"
232
+
],
233
+
"dev": true,
234
+
"license": "MIT",
235
+
"optional": true,
236
+
"os": [
237
+
"android"
238
+
],
239
+
"engines": {
240
+
"node": ">=18"
241
+
}
242
+
},
243
+
"node_modules/@esbuild/darwin-arm64": {
244
+
"version": "0.25.3",
245
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz",
246
+
"integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==",
247
+
"cpu": [
248
+
"arm64"
249
+
],
250
+
"dev": true,
251
+
"license": "MIT",
252
+
"optional": true,
253
+
"os": [
254
+
"darwin"
255
+
],
256
+
"engines": {
257
+
"node": ">=18"
258
+
}
259
+
},
260
+
"node_modules/@esbuild/darwin-x64": {
261
+
"version": "0.25.3",
262
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz",
263
+
"integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==",
264
+
"cpu": [
265
+
"x64"
266
+
],
267
+
"dev": true,
268
+
"license": "MIT",
269
+
"optional": true,
270
+
"os": [
271
+
"darwin"
272
+
],
273
+
"engines": {
274
+
"node": ">=18"
275
+
}
276
+
},
277
+
"node_modules/@esbuild/freebsd-arm64": {
278
+
"version": "0.25.3",
279
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz",
280
+
"integrity": "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==",
281
+
"cpu": [
282
+
"arm64"
283
+
],
284
+
"dev": true,
285
+
"license": "MIT",
286
+
"optional": true,
287
+
"os": [
288
+
"freebsd"
289
+
],
290
+
"engines": {
291
+
"node": ">=18"
292
+
}
293
+
},
294
+
"node_modules/@esbuild/freebsd-x64": {
295
+
"version": "0.25.3",
296
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz",
297
+
"integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==",
298
+
"cpu": [
299
+
"x64"
300
+
],
301
+
"dev": true,
302
+
"license": "MIT",
303
+
"optional": true,
304
+
"os": [
305
+
"freebsd"
306
+
],
307
+
"engines": {
308
+
"node": ">=18"
309
+
}
310
+
},
311
+
"node_modules/@esbuild/linux-arm": {
312
+
"version": "0.25.3",
313
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz",
314
+
"integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==",
315
+
"cpu": [
316
+
"arm"
317
+
],
318
+
"dev": true,
319
+
"license": "MIT",
320
+
"optional": true,
321
+
"os": [
322
+
"linux"
323
+
],
324
+
"engines": {
325
+
"node": ">=18"
326
+
}
327
+
},
328
+
"node_modules/@esbuild/linux-arm64": {
329
+
"version": "0.25.3",
330
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz",
331
+
"integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==",
332
+
"cpu": [
333
+
"arm64"
334
+
],
335
+
"dev": true,
336
+
"license": "MIT",
337
+
"optional": true,
338
+
"os": [
339
+
"linux"
340
+
],
341
+
"engines": {
342
+
"node": ">=18"
343
+
}
344
+
},
345
+
"node_modules/@esbuild/linux-ia32": {
346
+
"version": "0.25.3",
347
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz",
348
+
"integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==",
349
+
"cpu": [
350
+
"ia32"
351
+
],
352
+
"dev": true,
353
+
"license": "MIT",
354
+
"optional": true,
355
+
"os": [
356
+
"linux"
357
+
],
358
+
"engines": {
359
+
"node": ">=18"
360
+
}
361
+
},
362
+
"node_modules/@esbuild/linux-loong64": {
363
+
"version": "0.25.3",
364
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz",
365
+
"integrity": "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==",
366
+
"cpu": [
367
+
"loong64"
368
+
],
369
+
"dev": true,
370
+
"license": "MIT",
371
+
"optional": true,
372
+
"os": [
373
+
"linux"
374
+
],
375
+
"engines": {
376
+
"node": ">=18"
377
+
}
378
+
},
379
+
"node_modules/@esbuild/linux-mips64el": {
380
+
"version": "0.25.3",
381
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz",
382
+
"integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==",
383
+
"cpu": [
384
+
"mips64el"
385
+
],
386
+
"dev": true,
387
+
"license": "MIT",
388
+
"optional": true,
389
+
"os": [
390
+
"linux"
391
+
],
392
+
"engines": {
393
+
"node": ">=18"
394
+
}
395
+
},
396
+
"node_modules/@esbuild/linux-ppc64": {
397
+
"version": "0.25.3",
398
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz",
399
+
"integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==",
400
+
"cpu": [
401
+
"ppc64"
402
+
],
403
+
"dev": true,
404
+
"license": "MIT",
405
+
"optional": true,
406
+
"os": [
407
+
"linux"
408
+
],
409
+
"engines": {
410
+
"node": ">=18"
411
+
}
412
+
},
413
+
"node_modules/@esbuild/linux-riscv64": {
414
+
"version": "0.25.3",
415
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz",
416
+
"integrity": "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==",
417
+
"cpu": [
418
+
"riscv64"
419
+
],
420
+
"dev": true,
421
+
"license": "MIT",
422
+
"optional": true,
423
+
"os": [
424
+
"linux"
425
+
],
426
+
"engines": {
427
+
"node": ">=18"
428
+
}
429
+
},
430
+
"node_modules/@esbuild/linux-s390x": {
431
+
"version": "0.25.3",
432
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz",
433
+
"integrity": "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==",
434
+
"cpu": [
435
+
"s390x"
436
+
],
437
+
"dev": true,
438
+
"license": "MIT",
439
+
"optional": true,
440
+
"os": [
441
+
"linux"
442
+
],
443
+
"engines": {
444
+
"node": ">=18"
445
+
}
446
+
},
447
+
"node_modules/@esbuild/linux-x64": {
448
+
"version": "0.25.3",
449
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz",
450
+
"integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==",
451
+
"cpu": [
452
+
"x64"
453
+
],
454
+
"dev": true,
455
+
"license": "MIT",
456
+
"optional": true,
457
+
"os": [
458
+
"linux"
459
+
],
460
+
"engines": {
461
+
"node": ">=18"
462
+
}
463
+
},
464
+
"node_modules/@esbuild/netbsd-arm64": {
465
+
"version": "0.25.3",
466
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz",
467
+
"integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==",
468
+
"cpu": [
469
+
"arm64"
470
+
],
471
+
"dev": true,
472
+
"license": "MIT",
473
+
"optional": true,
474
+
"os": [
475
+
"netbsd"
476
+
],
477
+
"engines": {
478
+
"node": ">=18"
479
+
}
480
+
},
481
+
"node_modules/@esbuild/netbsd-x64": {
482
+
"version": "0.25.3",
483
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz",
484
+
"integrity": "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==",
485
+
"cpu": [
486
+
"x64"
487
+
],
488
+
"dev": true,
489
+
"license": "MIT",
490
+
"optional": true,
491
+
"os": [
492
+
"netbsd"
493
+
],
494
+
"engines": {
495
+
"node": ">=18"
496
+
}
497
+
},
498
+
"node_modules/@esbuild/openbsd-arm64": {
499
+
"version": "0.25.3",
500
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz",
501
+
"integrity": "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==",
502
+
"cpu": [
503
+
"arm64"
504
+
],
505
+
"dev": true,
506
+
"license": "MIT",
507
+
"optional": true,
508
+
"os": [
509
+
"openbsd"
510
+
],
511
+
"engines": {
512
+
"node": ">=18"
513
+
}
514
+
},
515
+
"node_modules/@esbuild/openbsd-x64": {
516
+
"version": "0.25.3",
517
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz",
518
+
"integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==",
519
+
"cpu": [
520
+
"x64"
521
+
],
522
+
"dev": true,
523
+
"license": "MIT",
524
+
"optional": true,
525
+
"os": [
526
+
"openbsd"
527
+
],
528
+
"engines": {
529
+
"node": ">=18"
530
+
}
531
+
},
532
+
"node_modules/@esbuild/sunos-x64": {
533
+
"version": "0.25.3",
534
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz",
535
+
"integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==",
536
+
"cpu": [
537
+
"x64"
538
+
],
539
+
"dev": true,
540
+
"license": "MIT",
541
+
"optional": true,
542
+
"os": [
543
+
"sunos"
544
+
],
545
+
"engines": {
546
+
"node": ">=18"
547
+
}
548
+
},
549
+
"node_modules/@esbuild/win32-arm64": {
550
+
"version": "0.25.3",
551
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz",
552
+
"integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==",
553
+
"cpu": [
554
+
"arm64"
555
+
],
556
+
"dev": true,
557
+
"license": "MIT",
558
+
"optional": true,
559
+
"os": [
560
+
"win32"
561
+
],
562
+
"engines": {
563
+
"node": ">=18"
564
+
}
565
+
},
566
+
"node_modules/@esbuild/win32-ia32": {
567
+
"version": "0.25.3",
568
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz",
569
+
"integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==",
570
+
"cpu": [
571
+
"ia32"
572
+
],
573
+
"dev": true,
574
+
"license": "MIT",
575
+
"optional": true,
576
+
"os": [
577
+
"win32"
578
+
],
579
+
"engines": {
580
+
"node": ">=18"
581
+
}
582
+
},
583
+
"node_modules/@esbuild/win32-x64": {
584
+
"version": "0.25.3",
585
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz",
586
+
"integrity": "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==",
587
+
"cpu": [
588
+
"x64"
589
+
],
590
+
"dev": true,
591
+
"license": "MIT",
592
+
"optional": true,
593
+
"os": [
594
+
"win32"
595
+
],
596
+
"engines": {
597
+
"node": ">=18"
598
+
}
599
+
},
600
+
"node_modules/@fastify/busboy": {
601
+
"version": "2.1.1",
602
+
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
603
+
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
604
+
"dev": true,
605
+
"license": "MIT",
606
+
"engines": {
607
+
"node": ">=14"
608
+
}
609
+
},
610
+
"node_modules/@img/sharp-darwin-arm64": {
611
+
"version": "0.33.5",
612
+
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
613
+
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
614
+
"cpu": [
615
+
"arm64"
616
+
],
617
+
"dev": true,
618
+
"license": "Apache-2.0",
619
+
"optional": true,
620
+
"os": [
621
+
"darwin"
622
+
],
623
+
"engines": {
624
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
625
+
},
626
+
"funding": {
627
+
"url": "https://opencollective.com/libvips"
628
+
},
629
+
"optionalDependencies": {
630
+
"@img/sharp-libvips-darwin-arm64": "1.0.4"
631
+
}
632
+
},
633
+
"node_modules/@img/sharp-darwin-x64": {
634
+
"version": "0.33.5",
635
+
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
636
+
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
637
+
"cpu": [
638
+
"x64"
639
+
],
640
+
"dev": true,
641
+
"license": "Apache-2.0",
642
+
"optional": true,
643
+
"os": [
644
+
"darwin"
645
+
],
646
+
"engines": {
647
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
648
+
},
649
+
"funding": {
650
+
"url": "https://opencollective.com/libvips"
651
+
},
652
+
"optionalDependencies": {
653
+
"@img/sharp-libvips-darwin-x64": "1.0.4"
654
+
}
655
+
},
656
+
"node_modules/@img/sharp-libvips-darwin-arm64": {
657
+
"version": "1.0.4",
658
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
659
+
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
660
+
"cpu": [
661
+
"arm64"
662
+
],
663
+
"dev": true,
664
+
"license": "LGPL-3.0-or-later",
665
+
"optional": true,
666
+
"os": [
667
+
"darwin"
668
+
],
669
+
"funding": {
670
+
"url": "https://opencollective.com/libvips"
671
+
}
672
+
},
673
+
"node_modules/@img/sharp-libvips-darwin-x64": {
674
+
"version": "1.0.4",
675
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
676
+
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
677
+
"cpu": [
678
+
"x64"
679
+
],
680
+
"dev": true,
681
+
"license": "LGPL-3.0-or-later",
682
+
"optional": true,
683
+
"os": [
684
+
"darwin"
685
+
],
686
+
"funding": {
687
+
"url": "https://opencollective.com/libvips"
688
+
}
689
+
},
690
+
"node_modules/@img/sharp-libvips-linux-arm": {
691
+
"version": "1.0.5",
692
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
693
+
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
694
+
"cpu": [
695
+
"arm"
696
+
],
697
+
"dev": true,
698
+
"license": "LGPL-3.0-or-later",
699
+
"optional": true,
700
+
"os": [
701
+
"linux"
702
+
],
703
+
"funding": {
704
+
"url": "https://opencollective.com/libvips"
705
+
}
706
+
},
707
+
"node_modules/@img/sharp-libvips-linux-arm64": {
708
+
"version": "1.0.4",
709
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
710
+
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
711
+
"cpu": [
712
+
"arm64"
713
+
],
714
+
"dev": true,
715
+
"license": "LGPL-3.0-or-later",
716
+
"optional": true,
717
+
"os": [
718
+
"linux"
719
+
],
720
+
"funding": {
721
+
"url": "https://opencollective.com/libvips"
722
+
}
723
+
},
724
+
"node_modules/@img/sharp-libvips-linux-s390x": {
725
+
"version": "1.0.4",
726
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
727
+
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
728
+
"cpu": [
729
+
"s390x"
730
+
],
731
+
"dev": true,
732
+
"license": "LGPL-3.0-or-later",
733
+
"optional": true,
734
+
"os": [
735
+
"linux"
736
+
],
737
+
"funding": {
738
+
"url": "https://opencollective.com/libvips"
739
+
}
740
+
},
741
+
"node_modules/@img/sharp-libvips-linux-x64": {
742
+
"version": "1.0.4",
743
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
744
+
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
745
+
"cpu": [
746
+
"x64"
747
+
],
748
+
"dev": true,
749
+
"license": "LGPL-3.0-or-later",
750
+
"optional": true,
751
+
"os": [
752
+
"linux"
753
+
],
754
+
"funding": {
755
+
"url": "https://opencollective.com/libvips"
756
+
}
757
+
},
758
+
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
759
+
"version": "1.0.4",
760
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
761
+
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
762
+
"cpu": [
763
+
"arm64"
764
+
],
765
+
"dev": true,
766
+
"license": "LGPL-3.0-or-later",
767
+
"optional": true,
768
+
"os": [
769
+
"linux"
770
+
],
771
+
"funding": {
772
+
"url": "https://opencollective.com/libvips"
773
+
}
774
+
},
775
+
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
776
+
"version": "1.0.4",
777
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
778
+
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
779
+
"cpu": [
780
+
"x64"
781
+
],
782
+
"dev": true,
783
+
"license": "LGPL-3.0-or-later",
784
+
"optional": true,
785
+
"os": [
786
+
"linux"
787
+
],
788
+
"funding": {
789
+
"url": "https://opencollective.com/libvips"
790
+
}
791
+
},
792
+
"node_modules/@img/sharp-linux-arm": {
793
+
"version": "0.33.5",
794
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
795
+
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
796
+
"cpu": [
797
+
"arm"
798
+
],
799
+
"dev": true,
800
+
"license": "Apache-2.0",
801
+
"optional": true,
802
+
"os": [
803
+
"linux"
804
+
],
805
+
"engines": {
806
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
807
+
},
808
+
"funding": {
809
+
"url": "https://opencollective.com/libvips"
810
+
},
811
+
"optionalDependencies": {
812
+
"@img/sharp-libvips-linux-arm": "1.0.5"
813
+
}
814
+
},
815
+
"node_modules/@img/sharp-linux-arm64": {
816
+
"version": "0.33.5",
817
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
818
+
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
819
+
"cpu": [
820
+
"arm64"
821
+
],
822
+
"dev": true,
823
+
"license": "Apache-2.0",
824
+
"optional": true,
825
+
"os": [
826
+
"linux"
827
+
],
828
+
"engines": {
829
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
830
+
},
831
+
"funding": {
832
+
"url": "https://opencollective.com/libvips"
833
+
},
834
+
"optionalDependencies": {
835
+
"@img/sharp-libvips-linux-arm64": "1.0.4"
836
+
}
837
+
},
838
+
"node_modules/@img/sharp-linux-s390x": {
839
+
"version": "0.33.5",
840
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
841
+
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
842
+
"cpu": [
843
+
"s390x"
844
+
],
845
+
"dev": true,
846
+
"license": "Apache-2.0",
847
+
"optional": true,
848
+
"os": [
849
+
"linux"
850
+
],
851
+
"engines": {
852
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
853
+
},
854
+
"funding": {
855
+
"url": "https://opencollective.com/libvips"
856
+
},
857
+
"optionalDependencies": {
858
+
"@img/sharp-libvips-linux-s390x": "1.0.4"
859
+
}
860
+
},
861
+
"node_modules/@img/sharp-linux-x64": {
862
+
"version": "0.33.5",
863
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
864
+
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
865
+
"cpu": [
866
+
"x64"
867
+
],
868
+
"dev": true,
869
+
"license": "Apache-2.0",
870
+
"optional": true,
871
+
"os": [
872
+
"linux"
873
+
],
874
+
"engines": {
875
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
876
+
},
877
+
"funding": {
878
+
"url": "https://opencollective.com/libvips"
879
+
},
880
+
"optionalDependencies": {
881
+
"@img/sharp-libvips-linux-x64": "1.0.4"
882
+
}
883
+
},
884
+
"node_modules/@img/sharp-linuxmusl-arm64": {
885
+
"version": "0.33.5",
886
+
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
887
+
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
888
+
"cpu": [
889
+
"arm64"
890
+
],
891
+
"dev": true,
892
+
"license": "Apache-2.0",
893
+
"optional": true,
894
+
"os": [
895
+
"linux"
896
+
],
897
+
"engines": {
898
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
899
+
},
900
+
"funding": {
901
+
"url": "https://opencollective.com/libvips"
902
+
},
903
+
"optionalDependencies": {
904
+
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
905
+
}
906
+
},
907
+
"node_modules/@img/sharp-linuxmusl-x64": {
908
+
"version": "0.33.5",
909
+
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
910
+
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
911
+
"cpu": [
912
+
"x64"
913
+
],
914
+
"dev": true,
915
+
"license": "Apache-2.0",
916
+
"optional": true,
917
+
"os": [
918
+
"linux"
919
+
],
920
+
"engines": {
921
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
922
+
},
923
+
"funding": {
924
+
"url": "https://opencollective.com/libvips"
925
+
},
926
+
"optionalDependencies": {
927
+
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
928
+
}
929
+
},
930
+
"node_modules/@img/sharp-wasm32": {
931
+
"version": "0.33.5",
932
+
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
933
+
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
934
+
"cpu": [
935
+
"wasm32"
936
+
],
937
+
"dev": true,
938
+
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
939
+
"optional": true,
940
+
"dependencies": {
941
+
"@emnapi/runtime": "^1.2.0"
942
+
},
943
+
"engines": {
944
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
945
+
},
946
+
"funding": {
947
+
"url": "https://opencollective.com/libvips"
948
+
}
949
+
},
950
+
"node_modules/@img/sharp-win32-ia32": {
951
+
"version": "0.33.5",
952
+
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
953
+
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
954
+
"cpu": [
955
+
"ia32"
956
+
],
957
+
"dev": true,
958
+
"license": "Apache-2.0 AND LGPL-3.0-or-later",
959
+
"optional": true,
960
+
"os": [
961
+
"win32"
962
+
],
963
+
"engines": {
964
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
965
+
},
966
+
"funding": {
967
+
"url": "https://opencollective.com/libvips"
968
+
}
969
+
},
970
+
"node_modules/@img/sharp-win32-x64": {
971
+
"version": "0.33.5",
972
+
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
973
+
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
974
+
"cpu": [
975
+
"x64"
976
+
],
977
+
"dev": true,
978
+
"license": "Apache-2.0 AND LGPL-3.0-or-later",
979
+
"optional": true,
980
+
"os": [
981
+
"win32"
982
+
],
983
+
"engines": {
984
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
985
+
},
986
+
"funding": {
987
+
"url": "https://opencollective.com/libvips"
988
+
}
989
+
},
990
+
"node_modules/@jridgewell/resolve-uri": {
991
+
"version": "3.1.2",
992
+
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
993
+
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
994
+
"dev": true,
995
+
"license": "MIT",
996
+
"engines": {
997
+
"node": ">=6.0.0"
998
+
}
999
+
},
1000
+
"node_modules/@jridgewell/sourcemap-codec": {
1001
+
"version": "1.5.0",
1002
+
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
1003
+
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
1004
+
"dev": true,
1005
+
"license": "MIT"
1006
+
},
1007
+
"node_modules/@jridgewell/trace-mapping": {
1008
+
"version": "0.3.9",
1009
+
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
1010
+
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
1011
+
"dev": true,
1012
+
"license": "MIT",
1013
+
"dependencies": {
1014
+
"@jridgewell/resolve-uri": "^3.0.3",
1015
+
"@jridgewell/sourcemap-codec": "^1.4.10"
1016
+
}
1017
+
},
1018
+
"node_modules/@rollup/rollup-android-arm-eabi": {
1019
+
"version": "4.40.1",
1020
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz",
1021
+
"integrity": "sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==",
1022
+
"cpu": [
1023
+
"arm"
1024
+
],
1025
+
"dev": true,
1026
+
"license": "MIT",
1027
+
"optional": true,
1028
+
"os": [
1029
+
"android"
1030
+
]
1031
+
},
1032
+
"node_modules/@rollup/rollup-android-arm64": {
1033
+
"version": "4.40.1",
1034
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.1.tgz",
1035
+
"integrity": "sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==",
1036
+
"cpu": [
1037
+
"arm64"
1038
+
],
1039
+
"dev": true,
1040
+
"license": "MIT",
1041
+
"optional": true,
1042
+
"os": [
1043
+
"android"
1044
+
]
1045
+
},
1046
+
"node_modules/@rollup/rollup-darwin-arm64": {
1047
+
"version": "4.40.1",
1048
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.1.tgz",
1049
+
"integrity": "sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==",
1050
+
"cpu": [
1051
+
"arm64"
1052
+
],
1053
+
"dev": true,
1054
+
"license": "MIT",
1055
+
"optional": true,
1056
+
"os": [
1057
+
"darwin"
1058
+
]
1059
+
},
1060
+
"node_modules/@rollup/rollup-darwin-x64": {
1061
+
"version": "4.40.1",
1062
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.1.tgz",
1063
+
"integrity": "sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==",
1064
+
"cpu": [
1065
+
"x64"
1066
+
],
1067
+
"dev": true,
1068
+
"license": "MIT",
1069
+
"optional": true,
1070
+
"os": [
1071
+
"darwin"
1072
+
]
1073
+
},
1074
+
"node_modules/@rollup/rollup-freebsd-arm64": {
1075
+
"version": "4.40.1",
1076
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.1.tgz",
1077
+
"integrity": "sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==",
1078
+
"cpu": [
1079
+
"arm64"
1080
+
],
1081
+
"dev": true,
1082
+
"license": "MIT",
1083
+
"optional": true,
1084
+
"os": [
1085
+
"freebsd"
1086
+
]
1087
+
},
1088
+
"node_modules/@rollup/rollup-freebsd-x64": {
1089
+
"version": "4.40.1",
1090
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.1.tgz",
1091
+
"integrity": "sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==",
1092
+
"cpu": [
1093
+
"x64"
1094
+
],
1095
+
"dev": true,
1096
+
"license": "MIT",
1097
+
"optional": true,
1098
+
"os": [
1099
+
"freebsd"
1100
+
]
1101
+
},
1102
+
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
1103
+
"version": "4.40.1",
1104
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.1.tgz",
1105
+
"integrity": "sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==",
1106
+
"cpu": [
1107
+
"arm"
1108
+
],
1109
+
"dev": true,
1110
+
"license": "MIT",
1111
+
"optional": true,
1112
+
"os": [
1113
+
"linux"
1114
+
]
1115
+
},
1116
+
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
1117
+
"version": "4.40.1",
1118
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.1.tgz",
1119
+
"integrity": "sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==",
1120
+
"cpu": [
1121
+
"arm"
1122
+
],
1123
+
"dev": true,
1124
+
"license": "MIT",
1125
+
"optional": true,
1126
+
"os": [
1127
+
"linux"
1128
+
]
1129
+
},
1130
+
"node_modules/@rollup/rollup-linux-arm64-gnu": {
1131
+
"version": "4.40.1",
1132
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.1.tgz",
1133
+
"integrity": "sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==",
1134
+
"cpu": [
1135
+
"arm64"
1136
+
],
1137
+
"dev": true,
1138
+
"license": "MIT",
1139
+
"optional": true,
1140
+
"os": [
1141
+
"linux"
1142
+
]
1143
+
},
1144
+
"node_modules/@rollup/rollup-linux-arm64-musl": {
1145
+
"version": "4.40.1",
1146
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.1.tgz",
1147
+
"integrity": "sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==",
1148
+
"cpu": [
1149
+
"arm64"
1150
+
],
1151
+
"dev": true,
1152
+
"license": "MIT",
1153
+
"optional": true,
1154
+
"os": [
1155
+
"linux"
1156
+
]
1157
+
},
1158
+
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
1159
+
"version": "4.40.1",
1160
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.1.tgz",
1161
+
"integrity": "sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==",
1162
+
"cpu": [
1163
+
"loong64"
1164
+
],
1165
+
"dev": true,
1166
+
"license": "MIT",
1167
+
"optional": true,
1168
+
"os": [
1169
+
"linux"
1170
+
]
1171
+
},
1172
+
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
1173
+
"version": "4.40.1",
1174
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.1.tgz",
1175
+
"integrity": "sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==",
1176
+
"cpu": [
1177
+
"ppc64"
1178
+
],
1179
+
"dev": true,
1180
+
"license": "MIT",
1181
+
"optional": true,
1182
+
"os": [
1183
+
"linux"
1184
+
]
1185
+
},
1186
+
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
1187
+
"version": "4.40.1",
1188
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.1.tgz",
1189
+
"integrity": "sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==",
1190
+
"cpu": [
1191
+
"riscv64"
1192
+
],
1193
+
"dev": true,
1194
+
"license": "MIT",
1195
+
"optional": true,
1196
+
"os": [
1197
+
"linux"
1198
+
]
1199
+
},
1200
+
"node_modules/@rollup/rollup-linux-riscv64-musl": {
1201
+
"version": "4.40.1",
1202
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.1.tgz",
1203
+
"integrity": "sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==",
1204
+
"cpu": [
1205
+
"riscv64"
1206
+
],
1207
+
"dev": true,
1208
+
"license": "MIT",
1209
+
"optional": true,
1210
+
"os": [
1211
+
"linux"
1212
+
]
1213
+
},
1214
+
"node_modules/@rollup/rollup-linux-s390x-gnu": {
1215
+
"version": "4.40.1",
1216
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.1.tgz",
1217
+
"integrity": "sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==",
1218
+
"cpu": [
1219
+
"s390x"
1220
+
],
1221
+
"dev": true,
1222
+
"license": "MIT",
1223
+
"optional": true,
1224
+
"os": [
1225
+
"linux"
1226
+
]
1227
+
},
1228
+
"node_modules/@rollup/rollup-linux-x64-gnu": {
1229
+
"version": "4.40.1",
1230
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.1.tgz",
1231
+
"integrity": "sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==",
1232
+
"cpu": [
1233
+
"x64"
1234
+
],
1235
+
"dev": true,
1236
+
"license": "MIT",
1237
+
"optional": true,
1238
+
"os": [
1239
+
"linux"
1240
+
]
1241
+
},
1242
+
"node_modules/@rollup/rollup-linux-x64-musl": {
1243
+
"version": "4.40.1",
1244
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.1.tgz",
1245
+
"integrity": "sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==",
1246
+
"cpu": [
1247
+
"x64"
1248
+
],
1249
+
"dev": true,
1250
+
"license": "MIT",
1251
+
"optional": true,
1252
+
"os": [
1253
+
"linux"
1254
+
]
1255
+
},
1256
+
"node_modules/@rollup/rollup-win32-arm64-msvc": {
1257
+
"version": "4.40.1",
1258
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.1.tgz",
1259
+
"integrity": "sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==",
1260
+
"cpu": [
1261
+
"arm64"
1262
+
],
1263
+
"dev": true,
1264
+
"license": "MIT",
1265
+
"optional": true,
1266
+
"os": [
1267
+
"win32"
1268
+
]
1269
+
},
1270
+
"node_modules/@rollup/rollup-win32-ia32-msvc": {
1271
+
"version": "4.40.1",
1272
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.1.tgz",
1273
+
"integrity": "sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==",
1274
+
"cpu": [
1275
+
"ia32"
1276
+
],
1277
+
"dev": true,
1278
+
"license": "MIT",
1279
+
"optional": true,
1280
+
"os": [
1281
+
"win32"
1282
+
]
1283
+
},
1284
+
"node_modules/@rollup/rollup-win32-x64-msvc": {
1285
+
"version": "4.40.1",
1286
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.1.tgz",
1287
+
"integrity": "sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==",
1288
+
"cpu": [
1289
+
"x64"
1290
+
],
1291
+
"dev": true,
1292
+
"license": "MIT",
1293
+
"optional": true,
1294
+
"os": [
1295
+
"win32"
1296
+
]
1297
+
},
1298
+
"node_modules/@types/estree": {
1299
+
"version": "1.0.7",
1300
+
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
1301
+
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
1302
+
"dev": true,
1303
+
"license": "MIT"
1304
+
},
1305
+
"node_modules/@vitest/expect": {
1306
+
"version": "3.0.9",
1307
+
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.9.tgz",
1308
+
"integrity": "sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==",
1309
+
"dev": true,
1310
+
"license": "MIT",
1311
+
"dependencies": {
1312
+
"@vitest/spy": "3.0.9",
1313
+
"@vitest/utils": "3.0.9",
1314
+
"chai": "^5.2.0",
1315
+
"tinyrainbow": "^2.0.0"
1316
+
},
1317
+
"funding": {
1318
+
"url": "https://opencollective.com/vitest"
1319
+
}
1320
+
},
1321
+
"node_modules/@vitest/mocker": {
1322
+
"version": "3.0.9",
1323
+
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.9.tgz",
1324
+
"integrity": "sha512-ryERPIBOnvevAkTq+L1lD+DTFBRcjueL9lOUfXsLfwP92h4e+Heb+PjiqS3/OURWPtywfafK0kj++yDFjWUmrA==",
1325
+
"dev": true,
1326
+
"license": "MIT",
1327
+
"dependencies": {
1328
+
"@vitest/spy": "3.0.9",
1329
+
"estree-walker": "^3.0.3",
1330
+
"magic-string": "^0.30.17"
1331
+
},
1332
+
"funding": {
1333
+
"url": "https://opencollective.com/vitest"
1334
+
},
1335
+
"peerDependencies": {
1336
+
"msw": "^2.4.9",
1337
+
"vite": "^5.0.0 || ^6.0.0"
1338
+
},
1339
+
"peerDependenciesMeta": {
1340
+
"msw": {
1341
+
"optional": true
1342
+
},
1343
+
"vite": {
1344
+
"optional": true
1345
+
}
1346
+
}
1347
+
},
1348
+
"node_modules/@vitest/pretty-format": {
1349
+
"version": "3.1.2",
1350
+
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz",
1351
+
"integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==",
1352
+
"dev": true,
1353
+
"license": "MIT",
1354
+
"dependencies": {
1355
+
"tinyrainbow": "^2.0.0"
1356
+
},
1357
+
"funding": {
1358
+
"url": "https://opencollective.com/vitest"
1359
+
}
1360
+
},
1361
+
"node_modules/@vitest/runner": {
1362
+
"version": "3.0.9",
1363
+
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.9.tgz",
1364
+
"integrity": "sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==",
1365
+
"dev": true,
1366
+
"license": "MIT",
1367
+
"dependencies": {
1368
+
"@vitest/utils": "3.0.9",
1369
+
"pathe": "^2.0.3"
1370
+
},
1371
+
"funding": {
1372
+
"url": "https://opencollective.com/vitest"
1373
+
}
1374
+
},
1375
+
"node_modules/@vitest/snapshot": {
1376
+
"version": "3.0.9",
1377
+
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.9.tgz",
1378
+
"integrity": "sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==",
1379
+
"dev": true,
1380
+
"license": "MIT",
1381
+
"dependencies": {
1382
+
"@vitest/pretty-format": "3.0.9",
1383
+
"magic-string": "^0.30.17",
1384
+
"pathe": "^2.0.3"
1385
+
},
1386
+
"funding": {
1387
+
"url": "https://opencollective.com/vitest"
1388
+
}
1389
+
},
1390
+
"node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
1391
+
"version": "3.0.9",
1392
+
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.9.tgz",
1393
+
"integrity": "sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==",
1394
+
"dev": true,
1395
+
"license": "MIT",
1396
+
"dependencies": {
1397
+
"tinyrainbow": "^2.0.0"
1398
+
},
1399
+
"funding": {
1400
+
"url": "https://opencollective.com/vitest"
1401
+
}
1402
+
},
1403
+
"node_modules/@vitest/spy": {
1404
+
"version": "3.0.9",
1405
+
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.9.tgz",
1406
+
"integrity": "sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==",
1407
+
"dev": true,
1408
+
"license": "MIT",
1409
+
"dependencies": {
1410
+
"tinyspy": "^3.0.2"
1411
+
},
1412
+
"funding": {
1413
+
"url": "https://opencollective.com/vitest"
1414
+
}
1415
+
},
1416
+
"node_modules/@vitest/utils": {
1417
+
"version": "3.0.9",
1418
+
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.9.tgz",
1419
+
"integrity": "sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==",
1420
+
"dev": true,
1421
+
"license": "MIT",
1422
+
"dependencies": {
1423
+
"@vitest/pretty-format": "3.0.9",
1424
+
"loupe": "^3.1.3",
1425
+
"tinyrainbow": "^2.0.0"
1426
+
},
1427
+
"funding": {
1428
+
"url": "https://opencollective.com/vitest"
1429
+
}
1430
+
},
1431
+
"node_modules/@vitest/utils/node_modules/@vitest/pretty-format": {
1432
+
"version": "3.0.9",
1433
+
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.9.tgz",
1434
+
"integrity": "sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==",
1435
+
"dev": true,
1436
+
"license": "MIT",
1437
+
"dependencies": {
1438
+
"tinyrainbow": "^2.0.0"
1439
+
},
1440
+
"funding": {
1441
+
"url": "https://opencollective.com/vitest"
1442
+
}
1443
+
},
1444
+
"node_modules/acorn": {
1445
+
"version": "8.14.0",
1446
+
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
1447
+
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
1448
+
"dev": true,
1449
+
"license": "MIT",
1450
+
"bin": {
1451
+
"acorn": "bin/acorn"
1452
+
},
1453
+
"engines": {
1454
+
"node": ">=0.4.0"
1455
+
}
1456
+
},
1457
+
"node_modules/acorn-walk": {
1458
+
"version": "8.3.2",
1459
+
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
1460
+
"integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
1461
+
"dev": true,
1462
+
"license": "MIT",
1463
+
"engines": {
1464
+
"node": ">=0.4.0"
1465
+
}
1466
+
},
1467
+
"node_modules/as-table": {
1468
+
"version": "1.0.55",
1469
+
"resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz",
1470
+
"integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==",
1471
+
"dev": true,
1472
+
"license": "MIT",
1473
+
"dependencies": {
1474
+
"printable-characters": "^1.0.42"
1475
+
}
1476
+
},
1477
+
"node_modules/assertion-error": {
1478
+
"version": "2.0.1",
1479
+
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
1480
+
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
1481
+
"dev": true,
1482
+
"license": "MIT",
1483
+
"engines": {
1484
+
"node": ">=12"
1485
+
}
1486
+
},
1487
+
"node_modules/birpc": {
1488
+
"version": "0.2.14",
1489
+
"resolved": "https://registry.npmjs.org/birpc/-/birpc-0.2.14.tgz",
1490
+
"integrity": "sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA==",
1491
+
"dev": true,
1492
+
"license": "MIT",
1493
+
"funding": {
1494
+
"url": "https://github.com/sponsors/antfu"
1495
+
}
1496
+
},
1497
+
"node_modules/blake3-wasm": {
1498
+
"version": "2.1.5",
1499
+
"resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
1500
+
"integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
1501
+
"dev": true,
1502
+
"license": "MIT"
1503
+
},
1504
+
"node_modules/cac": {
1505
+
"version": "6.7.14",
1506
+
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
1507
+
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
1508
+
"dev": true,
1509
+
"license": "MIT",
1510
+
"engines": {
1511
+
"node": ">=8"
1512
+
}
1513
+
},
1514
+
"node_modules/chai": {
1515
+
"version": "5.2.0",
1516
+
"resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
1517
+
"integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
1518
+
"dev": true,
1519
+
"license": "MIT",
1520
+
"dependencies": {
1521
+
"assertion-error": "^2.0.1",
1522
+
"check-error": "^2.1.1",
1523
+
"deep-eql": "^5.0.1",
1524
+
"loupe": "^3.1.0",
1525
+
"pathval": "^2.0.0"
1526
+
},
1527
+
"engines": {
1528
+
"node": ">=12"
1529
+
}
1530
+
},
1531
+
"node_modules/check-error": {
1532
+
"version": "2.1.1",
1533
+
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
1534
+
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
1535
+
"dev": true,
1536
+
"license": "MIT",
1537
+
"engines": {
1538
+
"node": ">= 16"
1539
+
}
1540
+
},
1541
+
"node_modules/cjs-module-lexer": {
1542
+
"version": "1.4.3",
1543
+
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
1544
+
"integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
1545
+
"dev": true,
1546
+
"license": "MIT"
1547
+
},
1548
+
"node_modules/color": {
1549
+
"version": "4.2.3",
1550
+
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
1551
+
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
1552
+
"dev": true,
1553
+
"license": "MIT",
1554
+
"optional": true,
1555
+
"dependencies": {
1556
+
"color-convert": "^2.0.1",
1557
+
"color-string": "^1.9.0"
1558
+
},
1559
+
"engines": {
1560
+
"node": ">=12.5.0"
1561
+
}
1562
+
},
1563
+
"node_modules/color-convert": {
1564
+
"version": "2.0.1",
1565
+
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
1566
+
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
1567
+
"dev": true,
1568
+
"license": "MIT",
1569
+
"optional": true,
1570
+
"dependencies": {
1571
+
"color-name": "~1.1.4"
1572
+
},
1573
+
"engines": {
1574
+
"node": ">=7.0.0"
1575
+
}
1576
+
},
1577
+
"node_modules/color-name": {
1578
+
"version": "1.1.4",
1579
+
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
1580
+
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
1581
+
"dev": true,
1582
+
"license": "MIT",
1583
+
"optional": true
1584
+
},
1585
+
"node_modules/color-string": {
1586
+
"version": "1.9.1",
1587
+
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
1588
+
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
1589
+
"dev": true,
1590
+
"license": "MIT",
1591
+
"optional": true,
1592
+
"dependencies": {
1593
+
"color-name": "^1.0.0",
1594
+
"simple-swizzle": "^0.2.2"
1595
+
}
1596
+
},
1597
+
"node_modules/cookie": {
1598
+
"version": "0.7.2",
1599
+
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
1600
+
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
1601
+
"dev": true,
1602
+
"license": "MIT",
1603
+
"engines": {
1604
+
"node": ">= 0.6"
1605
+
}
1606
+
},
1607
+
"node_modules/data-uri-to-buffer": {
1608
+
"version": "2.0.2",
1609
+
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz",
1610
+
"integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==",
1611
+
"dev": true,
1612
+
"license": "MIT"
1613
+
},
1614
+
"node_modules/debug": {
1615
+
"version": "4.4.0",
1616
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
1617
+
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
1618
+
"dev": true,
1619
+
"license": "MIT",
1620
+
"dependencies": {
1621
+
"ms": "^2.1.3"
1622
+
},
1623
+
"engines": {
1624
+
"node": ">=6.0"
1625
+
},
1626
+
"peerDependenciesMeta": {
1627
+
"supports-color": {
1628
+
"optional": true
1629
+
}
1630
+
}
1631
+
},
1632
+
"node_modules/deep-eql": {
1633
+
"version": "5.0.2",
1634
+
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
1635
+
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
1636
+
"dev": true,
1637
+
"license": "MIT",
1638
+
"engines": {
1639
+
"node": ">=6"
1640
+
}
1641
+
},
1642
+
"node_modules/defu": {
1643
+
"version": "6.1.4",
1644
+
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
1645
+
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
1646
+
"dev": true,
1647
+
"license": "MIT"
1648
+
},
1649
+
"node_modules/detect-libc": {
1650
+
"version": "2.0.4",
1651
+
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
1652
+
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
1653
+
"dev": true,
1654
+
"license": "Apache-2.0",
1655
+
"optional": true,
1656
+
"engines": {
1657
+
"node": ">=8"
1658
+
}
1659
+
},
1660
+
"node_modules/devalue": {
1661
+
"version": "4.3.3",
1662
+
"resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.3.tgz",
1663
+
"integrity": "sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg==",
1664
+
"dev": true,
1665
+
"license": "MIT"
1666
+
},
1667
+
"node_modules/es-module-lexer": {
1668
+
"version": "1.7.0",
1669
+
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
1670
+
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
1671
+
"dev": true,
1672
+
"license": "MIT"
1673
+
},
1674
+
"node_modules/esbuild": {
1675
+
"version": "0.25.3",
1676
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz",
1677
+
"integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==",
1678
+
"dev": true,
1679
+
"hasInstallScript": true,
1680
+
"license": "MIT",
1681
+
"bin": {
1682
+
"esbuild": "bin/esbuild"
1683
+
},
1684
+
"engines": {
1685
+
"node": ">=18"
1686
+
},
1687
+
"optionalDependencies": {
1688
+
"@esbuild/aix-ppc64": "0.25.3",
1689
+
"@esbuild/android-arm": "0.25.3",
1690
+
"@esbuild/android-arm64": "0.25.3",
1691
+
"@esbuild/android-x64": "0.25.3",
1692
+
"@esbuild/darwin-arm64": "0.25.3",
1693
+
"@esbuild/darwin-x64": "0.25.3",
1694
+
"@esbuild/freebsd-arm64": "0.25.3",
1695
+
"@esbuild/freebsd-x64": "0.25.3",
1696
+
"@esbuild/linux-arm": "0.25.3",
1697
+
"@esbuild/linux-arm64": "0.25.3",
1698
+
"@esbuild/linux-ia32": "0.25.3",
1699
+
"@esbuild/linux-loong64": "0.25.3",
1700
+
"@esbuild/linux-mips64el": "0.25.3",
1701
+
"@esbuild/linux-ppc64": "0.25.3",
1702
+
"@esbuild/linux-riscv64": "0.25.3",
1703
+
"@esbuild/linux-s390x": "0.25.3",
1704
+
"@esbuild/linux-x64": "0.25.3",
1705
+
"@esbuild/netbsd-arm64": "0.25.3",
1706
+
"@esbuild/netbsd-x64": "0.25.3",
1707
+
"@esbuild/openbsd-arm64": "0.25.3",
1708
+
"@esbuild/openbsd-x64": "0.25.3",
1709
+
"@esbuild/sunos-x64": "0.25.3",
1710
+
"@esbuild/win32-arm64": "0.25.3",
1711
+
"@esbuild/win32-ia32": "0.25.3",
1712
+
"@esbuild/win32-x64": "0.25.3"
1713
+
}
1714
+
},
1715
+
"node_modules/estree-walker": {
1716
+
"version": "3.0.3",
1717
+
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
1718
+
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
1719
+
"dev": true,
1720
+
"license": "MIT",
1721
+
"dependencies": {
1722
+
"@types/estree": "^1.0.0"
1723
+
}
1724
+
},
1725
+
"node_modules/exit-hook": {
1726
+
"version": "2.2.1",
1727
+
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
1728
+
"integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==",
1729
+
"dev": true,
1730
+
"license": "MIT",
1731
+
"engines": {
1732
+
"node": ">=6"
1733
+
},
1734
+
"funding": {
1735
+
"url": "https://github.com/sponsors/sindresorhus"
1736
+
}
1737
+
},
1738
+
"node_modules/expect-type": {
1739
+
"version": "1.2.1",
1740
+
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz",
1741
+
"integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==",
1742
+
"dev": true,
1743
+
"license": "Apache-2.0",
1744
+
"engines": {
1745
+
"node": ">=12.0.0"
1746
+
}
1747
+
},
1748
+
"node_modules/exsolve": {
1749
+
"version": "1.0.5",
1750
+
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.5.tgz",
1751
+
"integrity": "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==",
1752
+
"dev": true,
1753
+
"license": "MIT"
1754
+
},
1755
+
"node_modules/fdir": {
1756
+
"version": "6.4.4",
1757
+
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
1758
+
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
1759
+
"dev": true,
1760
+
"license": "MIT",
1761
+
"peerDependencies": {
1762
+
"picomatch": "^3 || ^4"
1763
+
},
1764
+
"peerDependenciesMeta": {
1765
+
"picomatch": {
1766
+
"optional": true
1767
+
}
1768
+
}
1769
+
},
1770
+
"node_modules/fsevents": {
1771
+
"version": "2.3.3",
1772
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1773
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1774
+
"dev": true,
1775
+
"hasInstallScript": true,
1776
+
"license": "MIT",
1777
+
"optional": true,
1778
+
"os": [
1779
+
"darwin"
1780
+
],
1781
+
"engines": {
1782
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1783
+
}
1784
+
},
1785
+
"node_modules/get-source": {
1786
+
"version": "2.0.12",
1787
+
"resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz",
1788
+
"integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==",
1789
+
"dev": true,
1790
+
"license": "Unlicense",
1791
+
"dependencies": {
1792
+
"data-uri-to-buffer": "^2.0.0",
1793
+
"source-map": "^0.6.1"
1794
+
}
1795
+
},
1796
+
"node_modules/glob-to-regexp": {
1797
+
"version": "0.4.1",
1798
+
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
1799
+
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
1800
+
"dev": true,
1801
+
"license": "BSD-2-Clause"
1802
+
},
1803
+
"node_modules/is-arrayish": {
1804
+
"version": "0.3.2",
1805
+
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
1806
+
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
1807
+
"dev": true,
1808
+
"license": "MIT",
1809
+
"optional": true
1810
+
},
1811
+
"node_modules/loupe": {
1812
+
"version": "3.1.3",
1813
+
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
1814
+
"integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==",
1815
+
"dev": true,
1816
+
"license": "MIT"
1817
+
},
1818
+
"node_modules/magic-string": {
1819
+
"version": "0.30.17",
1820
+
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
1821
+
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
1822
+
"dev": true,
1823
+
"license": "MIT",
1824
+
"dependencies": {
1825
+
"@jridgewell/sourcemap-codec": "^1.5.0"
1826
+
}
1827
+
},
1828
+
"node_modules/mime": {
1829
+
"version": "3.0.0",
1830
+
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
1831
+
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
1832
+
"dev": true,
1833
+
"license": "MIT",
1834
+
"bin": {
1835
+
"mime": "cli.js"
1836
+
},
1837
+
"engines": {
1838
+
"node": ">=10.0.0"
1839
+
}
1840
+
},
1841
+
"node_modules/miniflare": {
1842
+
"version": "4.20250428.1",
1843
+
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250428.1.tgz",
1844
+
"integrity": "sha512-M3qcJXjeAEimHrEeWXEhrJiC3YHB5M3QSqqK67pOTI+lHn0QyVG/2iFUjVJ/nv+i10uxeAEva8GRGeu+tKRCmQ==",
1845
+
"dev": true,
1846
+
"license": "MIT",
1847
+
"dependencies": {
1848
+
"@cspotcode/source-map-support": "0.8.1",
1849
+
"acorn": "8.14.0",
1850
+
"acorn-walk": "8.3.2",
1851
+
"exit-hook": "2.2.1",
1852
+
"glob-to-regexp": "0.4.1",
1853
+
"stoppable": "1.1.0",
1854
+
"undici": "^5.28.5",
1855
+
"workerd": "1.20250428.0",
1856
+
"ws": "8.18.0",
1857
+
"youch": "3.3.4",
1858
+
"zod": "3.22.3"
1859
+
},
1860
+
"bin": {
1861
+
"miniflare": "bootstrap.js"
1862
+
},
1863
+
"engines": {
1864
+
"node": ">=18.0.0"
1865
+
}
1866
+
},
1867
+
"node_modules/miniflare/node_modules/zod": {
1868
+
"version": "3.22.3",
1869
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz",
1870
+
"integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==",
1871
+
"dev": true,
1872
+
"license": "MIT",
1873
+
"funding": {
1874
+
"url": "https://github.com/sponsors/colinhacks"
1875
+
}
1876
+
},
1877
+
"node_modules/ms": {
1878
+
"version": "2.1.3",
1879
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1880
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1881
+
"dev": true,
1882
+
"license": "MIT"
1883
+
},
1884
+
"node_modules/mustache": {
1885
+
"version": "4.2.0",
1886
+
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
1887
+
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
1888
+
"dev": true,
1889
+
"license": "MIT",
1890
+
"bin": {
1891
+
"mustache": "bin/mustache"
1892
+
}
1893
+
},
1894
+
"node_modules/nanoid": {
1895
+
"version": "3.3.11",
1896
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
1897
+
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
1898
+
"dev": true,
1899
+
"funding": [
1900
+
{
1901
+
"type": "github",
1902
+
"url": "https://github.com/sponsors/ai"
1903
+
}
1904
+
],
1905
+
"license": "MIT",
1906
+
"bin": {
1907
+
"nanoid": "bin/nanoid.cjs"
1908
+
},
1909
+
"engines": {
1910
+
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1911
+
}
1912
+
},
1913
+
"node_modules/ohash": {
1914
+
"version": "2.0.11",
1915
+
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
1916
+
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
1917
+
"dev": true,
1918
+
"license": "MIT"
1919
+
},
1920
+
"node_modules/path-to-regexp": {
1921
+
"version": "6.3.0",
1922
+
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
1923
+
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
1924
+
"dev": true,
1925
+
"license": "MIT"
1926
+
},
1927
+
"node_modules/pathe": {
1928
+
"version": "2.0.3",
1929
+
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
1930
+
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
1931
+
"dev": true,
1932
+
"license": "MIT"
1933
+
},
1934
+
"node_modules/pathval": {
1935
+
"version": "2.0.0",
1936
+
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
1937
+
"integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
1938
+
"dev": true,
1939
+
"license": "MIT",
1940
+
"engines": {
1941
+
"node": ">= 14.16"
1942
+
}
1943
+
},
1944
+
"node_modules/picocolors": {
1945
+
"version": "1.1.1",
1946
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1947
+
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1948
+
"dev": true,
1949
+
"license": "ISC"
1950
+
},
1951
+
"node_modules/picomatch": {
1952
+
"version": "4.0.2",
1953
+
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
1954
+
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
1955
+
"dev": true,
1956
+
"license": "MIT",
1957
+
"engines": {
1958
+
"node": ">=12"
1959
+
},
1960
+
"funding": {
1961
+
"url": "https://github.com/sponsors/jonschlinkert"
1962
+
}
1963
+
},
1964
+
"node_modules/postcss": {
1965
+
"version": "8.5.3",
1966
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
1967
+
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
1968
+
"dev": true,
1969
+
"funding": [
1970
+
{
1971
+
"type": "opencollective",
1972
+
"url": "https://opencollective.com/postcss/"
1973
+
},
1974
+
{
1975
+
"type": "tidelift",
1976
+
"url": "https://tidelift.com/funding/github/npm/postcss"
1977
+
},
1978
+
{
1979
+
"type": "github",
1980
+
"url": "https://github.com/sponsors/ai"
1981
+
}
1982
+
],
1983
+
"license": "MIT",
1984
+
"dependencies": {
1985
+
"nanoid": "^3.3.8",
1986
+
"picocolors": "^1.1.1",
1987
+
"source-map-js": "^1.2.1"
1988
+
},
1989
+
"engines": {
1990
+
"node": "^10 || ^12 || >=14"
1991
+
}
1992
+
},
1993
+
"node_modules/printable-characters": {
1994
+
"version": "1.0.42",
1995
+
"resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz",
1996
+
"integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==",
1997
+
"dev": true,
1998
+
"license": "Unlicense"
1999
+
},
2000
+
"node_modules/rollup": {
2001
+
"version": "4.40.1",
2002
+
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.1.tgz",
2003
+
"integrity": "sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==",
2004
+
"dev": true,
2005
+
"license": "MIT",
2006
+
"dependencies": {
2007
+
"@types/estree": "1.0.7"
2008
+
},
2009
+
"bin": {
2010
+
"rollup": "dist/bin/rollup"
2011
+
},
2012
+
"engines": {
2013
+
"node": ">=18.0.0",
2014
+
"npm": ">=8.0.0"
2015
+
},
2016
+
"optionalDependencies": {
2017
+
"@rollup/rollup-android-arm-eabi": "4.40.1",
2018
+
"@rollup/rollup-android-arm64": "4.40.1",
2019
+
"@rollup/rollup-darwin-arm64": "4.40.1",
2020
+
"@rollup/rollup-darwin-x64": "4.40.1",
2021
+
"@rollup/rollup-freebsd-arm64": "4.40.1",
2022
+
"@rollup/rollup-freebsd-x64": "4.40.1",
2023
+
"@rollup/rollup-linux-arm-gnueabihf": "4.40.1",
2024
+
"@rollup/rollup-linux-arm-musleabihf": "4.40.1",
2025
+
"@rollup/rollup-linux-arm64-gnu": "4.40.1",
2026
+
"@rollup/rollup-linux-arm64-musl": "4.40.1",
2027
+
"@rollup/rollup-linux-loongarch64-gnu": "4.40.1",
2028
+
"@rollup/rollup-linux-powerpc64le-gnu": "4.40.1",
2029
+
"@rollup/rollup-linux-riscv64-gnu": "4.40.1",
2030
+
"@rollup/rollup-linux-riscv64-musl": "4.40.1",
2031
+
"@rollup/rollup-linux-s390x-gnu": "4.40.1",
2032
+
"@rollup/rollup-linux-x64-gnu": "4.40.1",
2033
+
"@rollup/rollup-linux-x64-musl": "4.40.1",
2034
+
"@rollup/rollup-win32-arm64-msvc": "4.40.1",
2035
+
"@rollup/rollup-win32-ia32-msvc": "4.40.1",
2036
+
"@rollup/rollup-win32-x64-msvc": "4.40.1",
2037
+
"fsevents": "~2.3.2"
2038
+
}
2039
+
},
2040
+
"node_modules/semver": {
2041
+
"version": "7.7.1",
2042
+
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
2043
+
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
2044
+
"dev": true,
2045
+
"license": "ISC",
2046
+
"bin": {
2047
+
"semver": "bin/semver.js"
2048
+
},
2049
+
"engines": {
2050
+
"node": ">=10"
2051
+
}
2052
+
},
2053
+
"node_modules/sharp": {
2054
+
"version": "0.33.5",
2055
+
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
2056
+
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
2057
+
"dev": true,
2058
+
"hasInstallScript": true,
2059
+
"license": "Apache-2.0",
2060
+
"optional": true,
2061
+
"dependencies": {
2062
+
"color": "^4.2.3",
2063
+
"detect-libc": "^2.0.3",
2064
+
"semver": "^7.6.3"
2065
+
},
2066
+
"engines": {
2067
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
2068
+
},
2069
+
"funding": {
2070
+
"url": "https://opencollective.com/libvips"
2071
+
},
2072
+
"optionalDependencies": {
2073
+
"@img/sharp-darwin-arm64": "0.33.5",
2074
+
"@img/sharp-darwin-x64": "0.33.5",
2075
+
"@img/sharp-libvips-darwin-arm64": "1.0.4",
2076
+
"@img/sharp-libvips-darwin-x64": "1.0.4",
2077
+
"@img/sharp-libvips-linux-arm": "1.0.5",
2078
+
"@img/sharp-libvips-linux-arm64": "1.0.4",
2079
+
"@img/sharp-libvips-linux-s390x": "1.0.4",
2080
+
"@img/sharp-libvips-linux-x64": "1.0.4",
2081
+
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
2082
+
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
2083
+
"@img/sharp-linux-arm": "0.33.5",
2084
+
"@img/sharp-linux-arm64": "0.33.5",
2085
+
"@img/sharp-linux-s390x": "0.33.5",
2086
+
"@img/sharp-linux-x64": "0.33.5",
2087
+
"@img/sharp-linuxmusl-arm64": "0.33.5",
2088
+
"@img/sharp-linuxmusl-x64": "0.33.5",
2089
+
"@img/sharp-wasm32": "0.33.5",
2090
+
"@img/sharp-win32-ia32": "0.33.5",
2091
+
"@img/sharp-win32-x64": "0.33.5"
2092
+
}
2093
+
},
2094
+
"node_modules/siginfo": {
2095
+
"version": "2.0.0",
2096
+
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
2097
+
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
2098
+
"dev": true,
2099
+
"license": "ISC"
2100
+
},
2101
+
"node_modules/simple-swizzle": {
2102
+
"version": "0.2.2",
2103
+
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
2104
+
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
2105
+
"dev": true,
2106
+
"license": "MIT",
2107
+
"optional": true,
2108
+
"dependencies": {
2109
+
"is-arrayish": "^0.3.1"
2110
+
}
2111
+
},
2112
+
"node_modules/source-map": {
2113
+
"version": "0.6.1",
2114
+
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
2115
+
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
2116
+
"dev": true,
2117
+
"license": "BSD-3-Clause",
2118
+
"engines": {
2119
+
"node": ">=0.10.0"
2120
+
}
2121
+
},
2122
+
"node_modules/source-map-js": {
2123
+
"version": "1.2.1",
2124
+
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
2125
+
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2126
+
"dev": true,
2127
+
"license": "BSD-3-Clause",
2128
+
"engines": {
2129
+
"node": ">=0.10.0"
2130
+
}
2131
+
},
2132
+
"node_modules/stackback": {
2133
+
"version": "0.0.2",
2134
+
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
2135
+
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
2136
+
"dev": true,
2137
+
"license": "MIT"
2138
+
},
2139
+
"node_modules/stacktracey": {
2140
+
"version": "2.1.8",
2141
+
"resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz",
2142
+
"integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==",
2143
+
"dev": true,
2144
+
"license": "Unlicense",
2145
+
"dependencies": {
2146
+
"as-table": "^1.0.36",
2147
+
"get-source": "^2.0.12"
2148
+
}
2149
+
},
2150
+
"node_modules/std-env": {
2151
+
"version": "3.9.0",
2152
+
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
2153
+
"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
2154
+
"dev": true,
2155
+
"license": "MIT"
2156
+
},
2157
+
"node_modules/stoppable": {
2158
+
"version": "1.1.0",
2159
+
"resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz",
2160
+
"integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==",
2161
+
"dev": true,
2162
+
"license": "MIT",
2163
+
"engines": {
2164
+
"node": ">=4",
2165
+
"npm": ">=6"
2166
+
}
2167
+
},
2168
+
"node_modules/tinybench": {
2169
+
"version": "2.9.0",
2170
+
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
2171
+
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
2172
+
"dev": true,
2173
+
"license": "MIT"
2174
+
},
2175
+
"node_modules/tinyexec": {
2176
+
"version": "0.3.2",
2177
+
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
2178
+
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
2179
+
"dev": true,
2180
+
"license": "MIT"
2181
+
},
2182
+
"node_modules/tinyglobby": {
2183
+
"version": "0.2.13",
2184
+
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
2185
+
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
2186
+
"dev": true,
2187
+
"license": "MIT",
2188
+
"dependencies": {
2189
+
"fdir": "^6.4.4",
2190
+
"picomatch": "^4.0.2"
2191
+
},
2192
+
"engines": {
2193
+
"node": ">=12.0.0"
2194
+
},
2195
+
"funding": {
2196
+
"url": "https://github.com/sponsors/SuperchupuDev"
2197
+
}
2198
+
},
2199
+
"node_modules/tinypool": {
2200
+
"version": "1.0.2",
2201
+
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz",
2202
+
"integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==",
2203
+
"dev": true,
2204
+
"license": "MIT",
2205
+
"engines": {
2206
+
"node": "^18.0.0 || >=20.0.0"
2207
+
}
2208
+
},
2209
+
"node_modules/tinyrainbow": {
2210
+
"version": "2.0.0",
2211
+
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
2212
+
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
2213
+
"dev": true,
2214
+
"license": "MIT",
2215
+
"engines": {
2216
+
"node": ">=14.0.0"
2217
+
}
2218
+
},
2219
+
"node_modules/tinyspy": {
2220
+
"version": "3.0.2",
2221
+
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
2222
+
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
2223
+
"dev": true,
2224
+
"license": "MIT",
2225
+
"engines": {
2226
+
"node": ">=14.0.0"
2227
+
}
2228
+
},
2229
+
"node_modules/tslib": {
2230
+
"version": "2.8.1",
2231
+
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
2232
+
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
2233
+
"dev": true,
2234
+
"license": "0BSD",
2235
+
"optional": true
2236
+
},
2237
+
"node_modules/ufo": {
2238
+
"version": "1.6.1",
2239
+
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
2240
+
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
2241
+
"dev": true,
2242
+
"license": "MIT"
2243
+
},
2244
+
"node_modules/undici": {
2245
+
"version": "5.29.0",
2246
+
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
2247
+
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
2248
+
"dev": true,
2249
+
"license": "MIT",
2250
+
"dependencies": {
2251
+
"@fastify/busboy": "^2.0.0"
2252
+
},
2253
+
"engines": {
2254
+
"node": ">=14.0"
2255
+
}
2256
+
},
2257
+
"node_modules/unenv": {
2258
+
"version": "2.0.0-rc.15",
2259
+
"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.15.tgz",
2260
+
"integrity": "sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA==",
2261
+
"dev": true,
2262
+
"license": "MIT",
2263
+
"dependencies": {
2264
+
"defu": "^6.1.4",
2265
+
"exsolve": "^1.0.4",
2266
+
"ohash": "^2.0.11",
2267
+
"pathe": "^2.0.3",
2268
+
"ufo": "^1.5.4"
2269
+
}
2270
+
},
2271
+
"node_modules/vite": {
2272
+
"version": "6.3.4",
2273
+
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz",
2274
+
"integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==",
2275
+
"dev": true,
2276
+
"license": "MIT",
2277
+
"dependencies": {
2278
+
"esbuild": "^0.25.0",
2279
+
"fdir": "^6.4.4",
2280
+
"picomatch": "^4.0.2",
2281
+
"postcss": "^8.5.3",
2282
+
"rollup": "^4.34.9",
2283
+
"tinyglobby": "^0.2.13"
2284
+
},
2285
+
"bin": {
2286
+
"vite": "bin/vite.js"
2287
+
},
2288
+
"engines": {
2289
+
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
2290
+
},
2291
+
"funding": {
2292
+
"url": "https://github.com/vitejs/vite?sponsor=1"
2293
+
},
2294
+
"optionalDependencies": {
2295
+
"fsevents": "~2.3.3"
2296
+
},
2297
+
"peerDependencies": {
2298
+
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
2299
+
"jiti": ">=1.21.0",
2300
+
"less": "*",
2301
+
"lightningcss": "^1.21.0",
2302
+
"sass": "*",
2303
+
"sass-embedded": "*",
2304
+
"stylus": "*",
2305
+
"sugarss": "*",
2306
+
"terser": "^5.16.0",
2307
+
"tsx": "^4.8.1",
2308
+
"yaml": "^2.4.2"
2309
+
},
2310
+
"peerDependenciesMeta": {
2311
+
"@types/node": {
2312
+
"optional": true
2313
+
},
2314
+
"jiti": {
2315
+
"optional": true
2316
+
},
2317
+
"less": {
2318
+
"optional": true
2319
+
},
2320
+
"lightningcss": {
2321
+
"optional": true
2322
+
},
2323
+
"sass": {
2324
+
"optional": true
2325
+
},
2326
+
"sass-embedded": {
2327
+
"optional": true
2328
+
},
2329
+
"stylus": {
2330
+
"optional": true
2331
+
},
2332
+
"sugarss": {
2333
+
"optional": true
2334
+
},
2335
+
"terser": {
2336
+
"optional": true
2337
+
},
2338
+
"tsx": {
2339
+
"optional": true
2340
+
},
2341
+
"yaml": {
2342
+
"optional": true
2343
+
}
2344
+
}
2345
+
},
2346
+
"node_modules/vite-node": {
2347
+
"version": "3.0.9",
2348
+
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.9.tgz",
2349
+
"integrity": "sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg==",
2350
+
"dev": true,
2351
+
"license": "MIT",
2352
+
"dependencies": {
2353
+
"cac": "^6.7.14",
2354
+
"debug": "^4.4.0",
2355
+
"es-module-lexer": "^1.6.0",
2356
+
"pathe": "^2.0.3",
2357
+
"vite": "^5.0.0 || ^6.0.0"
2358
+
},
2359
+
"bin": {
2360
+
"vite-node": "vite-node.mjs"
2361
+
},
2362
+
"engines": {
2363
+
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
2364
+
},
2365
+
"funding": {
2366
+
"url": "https://opencollective.com/vitest"
2367
+
}
2368
+
},
2369
+
"node_modules/vitest": {
2370
+
"version": "3.0.9",
2371
+
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.9.tgz",
2372
+
"integrity": "sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==",
2373
+
"dev": true,
2374
+
"license": "MIT",
2375
+
"dependencies": {
2376
+
"@vitest/expect": "3.0.9",
2377
+
"@vitest/mocker": "3.0.9",
2378
+
"@vitest/pretty-format": "^3.0.9",
2379
+
"@vitest/runner": "3.0.9",
2380
+
"@vitest/snapshot": "3.0.9",
2381
+
"@vitest/spy": "3.0.9",
2382
+
"@vitest/utils": "3.0.9",
2383
+
"chai": "^5.2.0",
2384
+
"debug": "^4.4.0",
2385
+
"expect-type": "^1.1.0",
2386
+
"magic-string": "^0.30.17",
2387
+
"pathe": "^2.0.3",
2388
+
"std-env": "^3.8.0",
2389
+
"tinybench": "^2.9.0",
2390
+
"tinyexec": "^0.3.2",
2391
+
"tinypool": "^1.0.2",
2392
+
"tinyrainbow": "^2.0.0",
2393
+
"vite": "^5.0.0 || ^6.0.0",
2394
+
"vite-node": "3.0.9",
2395
+
"why-is-node-running": "^2.3.0"
2396
+
},
2397
+
"bin": {
2398
+
"vitest": "vitest.mjs"
2399
+
},
2400
+
"engines": {
2401
+
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
2402
+
},
2403
+
"funding": {
2404
+
"url": "https://opencollective.com/vitest"
2405
+
},
2406
+
"peerDependencies": {
2407
+
"@edge-runtime/vm": "*",
2408
+
"@types/debug": "^4.1.12",
2409
+
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
2410
+
"@vitest/browser": "3.0.9",
2411
+
"@vitest/ui": "3.0.9",
2412
+
"happy-dom": "*",
2413
+
"jsdom": "*"
2414
+
},
2415
+
"peerDependenciesMeta": {
2416
+
"@edge-runtime/vm": {
2417
+
"optional": true
2418
+
},
2419
+
"@types/debug": {
2420
+
"optional": true
2421
+
},
2422
+
"@types/node": {
2423
+
"optional": true
2424
+
},
2425
+
"@vitest/browser": {
2426
+
"optional": true
2427
+
},
2428
+
"@vitest/ui": {
2429
+
"optional": true
2430
+
},
2431
+
"happy-dom": {
2432
+
"optional": true
2433
+
},
2434
+
"jsdom": {
2435
+
"optional": true
2436
+
}
2437
+
}
2438
+
},
2439
+
"node_modules/why-is-node-running": {
2440
+
"version": "2.3.0",
2441
+
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
2442
+
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
2443
+
"dev": true,
2444
+
"license": "MIT",
2445
+
"dependencies": {
2446
+
"siginfo": "^2.0.0",
2447
+
"stackback": "0.0.2"
2448
+
},
2449
+
"bin": {
2450
+
"why-is-node-running": "cli.js"
2451
+
},
2452
+
"engines": {
2453
+
"node": ">=8"
2454
+
}
2455
+
},
2456
+
"node_modules/workerd": {
2457
+
"version": "1.20250428.0",
2458
+
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250428.0.tgz",
2459
+
"integrity": "sha512-JJNWkHkwPQKQdvtM9UORijgYdcdJsihA4SfYjwh02IUQsdMyZ9jizV1sX9yWi9B9ptlohTW8UNHJEATuphGgdg==",
2460
+
"dev": true,
2461
+
"hasInstallScript": true,
2462
+
"license": "Apache-2.0",
2463
+
"bin": {
2464
+
"workerd": "bin/workerd"
2465
+
},
2466
+
"engines": {
2467
+
"node": ">=16"
2468
+
},
2469
+
"optionalDependencies": {
2470
+
"@cloudflare/workerd-darwin-64": "1.20250428.0",
2471
+
"@cloudflare/workerd-darwin-arm64": "1.20250428.0",
2472
+
"@cloudflare/workerd-linux-64": "1.20250428.0",
2473
+
"@cloudflare/workerd-linux-arm64": "1.20250428.0",
2474
+
"@cloudflare/workerd-windows-64": "1.20250428.0"
2475
+
}
2476
+
},
2477
+
"node_modules/wrangler": {
2478
+
"version": "4.14.1",
2479
+
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.14.1.tgz",
2480
+
"integrity": "sha512-EU7IThP7i68TBftJJSveogvWZ5k/WRijcJh3UclDWiWWhDZTPbL6LOJEFhHKqFzHOaC4Y2Aewt48rfTz0e7oCw==",
2481
+
"dev": true,
2482
+
"license": "MIT OR Apache-2.0",
2483
+
"dependencies": {
2484
+
"@cloudflare/kv-asset-handler": "0.4.0",
2485
+
"@cloudflare/unenv-preset": "2.3.1",
2486
+
"blake3-wasm": "2.1.5",
2487
+
"esbuild": "0.25.2",
2488
+
"miniflare": "4.20250428.1",
2489
+
"path-to-regexp": "6.3.0",
2490
+
"unenv": "2.0.0-rc.15",
2491
+
"workerd": "1.20250428.0"
2492
+
},
2493
+
"bin": {
2494
+
"wrangler": "bin/wrangler.js",
2495
+
"wrangler2": "bin/wrangler.js"
2496
+
},
2497
+
"engines": {
2498
+
"node": ">=18.0.0"
2499
+
},
2500
+
"optionalDependencies": {
2501
+
"fsevents": "~2.3.2",
2502
+
"sharp": "^0.33.5"
2503
+
},
2504
+
"peerDependencies": {
2505
+
"@cloudflare/workers-types": "^4.20250428.0"
2506
+
},
2507
+
"peerDependenciesMeta": {
2508
+
"@cloudflare/workers-types": {
2509
+
"optional": true
2510
+
}
2511
+
}
2512
+
},
2513
+
"node_modules/wrangler/node_modules/@esbuild/aix-ppc64": {
2514
+
"version": "0.25.2",
2515
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
2516
+
"integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==",
2517
+
"cpu": [
2518
+
"ppc64"
2519
+
],
2520
+
"dev": true,
2521
+
"license": "MIT",
2522
+
"optional": true,
2523
+
"os": [
2524
+
"aix"
2525
+
],
2526
+
"engines": {
2527
+
"node": ">=18"
2528
+
}
2529
+
},
2530
+
"node_modules/wrangler/node_modules/@esbuild/android-arm": {
2531
+
"version": "0.25.2",
2532
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz",
2533
+
"integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==",
2534
+
"cpu": [
2535
+
"arm"
2536
+
],
2537
+
"dev": true,
2538
+
"license": "MIT",
2539
+
"optional": true,
2540
+
"os": [
2541
+
"android"
2542
+
],
2543
+
"engines": {
2544
+
"node": ">=18"
2545
+
}
2546
+
},
2547
+
"node_modules/wrangler/node_modules/@esbuild/android-arm64": {
2548
+
"version": "0.25.2",
2549
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz",
2550
+
"integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==",
2551
+
"cpu": [
2552
+
"arm64"
2553
+
],
2554
+
"dev": true,
2555
+
"license": "MIT",
2556
+
"optional": true,
2557
+
"os": [
2558
+
"android"
2559
+
],
2560
+
"engines": {
2561
+
"node": ">=18"
2562
+
}
2563
+
},
2564
+
"node_modules/wrangler/node_modules/@esbuild/android-x64": {
2565
+
"version": "0.25.2",
2566
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz",
2567
+
"integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==",
2568
+
"cpu": [
2569
+
"x64"
2570
+
],
2571
+
"dev": true,
2572
+
"license": "MIT",
2573
+
"optional": true,
2574
+
"os": [
2575
+
"android"
2576
+
],
2577
+
"engines": {
2578
+
"node": ">=18"
2579
+
}
2580
+
},
2581
+
"node_modules/wrangler/node_modules/@esbuild/darwin-arm64": {
2582
+
"version": "0.25.2",
2583
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz",
2584
+
"integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==",
2585
+
"cpu": [
2586
+
"arm64"
2587
+
],
2588
+
"dev": true,
2589
+
"license": "MIT",
2590
+
"optional": true,
2591
+
"os": [
2592
+
"darwin"
2593
+
],
2594
+
"engines": {
2595
+
"node": ">=18"
2596
+
}
2597
+
},
2598
+
"node_modules/wrangler/node_modules/@esbuild/darwin-x64": {
2599
+
"version": "0.25.2",
2600
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz",
2601
+
"integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==",
2602
+
"cpu": [
2603
+
"x64"
2604
+
],
2605
+
"dev": true,
2606
+
"license": "MIT",
2607
+
"optional": true,
2608
+
"os": [
2609
+
"darwin"
2610
+
],
2611
+
"engines": {
2612
+
"node": ">=18"
2613
+
}
2614
+
},
2615
+
"node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": {
2616
+
"version": "0.25.2",
2617
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz",
2618
+
"integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==",
2619
+
"cpu": [
2620
+
"arm64"
2621
+
],
2622
+
"dev": true,
2623
+
"license": "MIT",
2624
+
"optional": true,
2625
+
"os": [
2626
+
"freebsd"
2627
+
],
2628
+
"engines": {
2629
+
"node": ">=18"
2630
+
}
2631
+
},
2632
+
"node_modules/wrangler/node_modules/@esbuild/freebsd-x64": {
2633
+
"version": "0.25.2",
2634
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz",
2635
+
"integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==",
2636
+
"cpu": [
2637
+
"x64"
2638
+
],
2639
+
"dev": true,
2640
+
"license": "MIT",
2641
+
"optional": true,
2642
+
"os": [
2643
+
"freebsd"
2644
+
],
2645
+
"engines": {
2646
+
"node": ">=18"
2647
+
}
2648
+
},
2649
+
"node_modules/wrangler/node_modules/@esbuild/linux-arm": {
2650
+
"version": "0.25.2",
2651
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz",
2652
+
"integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==",
2653
+
"cpu": [
2654
+
"arm"
2655
+
],
2656
+
"dev": true,
2657
+
"license": "MIT",
2658
+
"optional": true,
2659
+
"os": [
2660
+
"linux"
2661
+
],
2662
+
"engines": {
2663
+
"node": ">=18"
2664
+
}
2665
+
},
2666
+
"node_modules/wrangler/node_modules/@esbuild/linux-arm64": {
2667
+
"version": "0.25.2",
2668
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz",
2669
+
"integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==",
2670
+
"cpu": [
2671
+
"arm64"
2672
+
],
2673
+
"dev": true,
2674
+
"license": "MIT",
2675
+
"optional": true,
2676
+
"os": [
2677
+
"linux"
2678
+
],
2679
+
"engines": {
2680
+
"node": ">=18"
2681
+
}
2682
+
},
2683
+
"node_modules/wrangler/node_modules/@esbuild/linux-ia32": {
2684
+
"version": "0.25.2",
2685
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz",
2686
+
"integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==",
2687
+
"cpu": [
2688
+
"ia32"
2689
+
],
2690
+
"dev": true,
2691
+
"license": "MIT",
2692
+
"optional": true,
2693
+
"os": [
2694
+
"linux"
2695
+
],
2696
+
"engines": {
2697
+
"node": ">=18"
2698
+
}
2699
+
},
2700
+
"node_modules/wrangler/node_modules/@esbuild/linux-loong64": {
2701
+
"version": "0.25.2",
2702
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz",
2703
+
"integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==",
2704
+
"cpu": [
2705
+
"loong64"
2706
+
],
2707
+
"dev": true,
2708
+
"license": "MIT",
2709
+
"optional": true,
2710
+
"os": [
2711
+
"linux"
2712
+
],
2713
+
"engines": {
2714
+
"node": ">=18"
2715
+
}
2716
+
},
2717
+
"node_modules/wrangler/node_modules/@esbuild/linux-mips64el": {
2718
+
"version": "0.25.2",
2719
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz",
2720
+
"integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==",
2721
+
"cpu": [
2722
+
"mips64el"
2723
+
],
2724
+
"dev": true,
2725
+
"license": "MIT",
2726
+
"optional": true,
2727
+
"os": [
2728
+
"linux"
2729
+
],
2730
+
"engines": {
2731
+
"node": ">=18"
2732
+
}
2733
+
},
2734
+
"node_modules/wrangler/node_modules/@esbuild/linux-ppc64": {
2735
+
"version": "0.25.2",
2736
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz",
2737
+
"integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==",
2738
+
"cpu": [
2739
+
"ppc64"
2740
+
],
2741
+
"dev": true,
2742
+
"license": "MIT",
2743
+
"optional": true,
2744
+
"os": [
2745
+
"linux"
2746
+
],
2747
+
"engines": {
2748
+
"node": ">=18"
2749
+
}
2750
+
},
2751
+
"node_modules/wrangler/node_modules/@esbuild/linux-riscv64": {
2752
+
"version": "0.25.2",
2753
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz",
2754
+
"integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==",
2755
+
"cpu": [
2756
+
"riscv64"
2757
+
],
2758
+
"dev": true,
2759
+
"license": "MIT",
2760
+
"optional": true,
2761
+
"os": [
2762
+
"linux"
2763
+
],
2764
+
"engines": {
2765
+
"node": ">=18"
2766
+
}
2767
+
},
2768
+
"node_modules/wrangler/node_modules/@esbuild/linux-s390x": {
2769
+
"version": "0.25.2",
2770
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz",
2771
+
"integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==",
2772
+
"cpu": [
2773
+
"s390x"
2774
+
],
2775
+
"dev": true,
2776
+
"license": "MIT",
2777
+
"optional": true,
2778
+
"os": [
2779
+
"linux"
2780
+
],
2781
+
"engines": {
2782
+
"node": ">=18"
2783
+
}
2784
+
},
2785
+
"node_modules/wrangler/node_modules/@esbuild/linux-x64": {
2786
+
"version": "0.25.2",
2787
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz",
2788
+
"integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==",
2789
+
"cpu": [
2790
+
"x64"
2791
+
],
2792
+
"dev": true,
2793
+
"license": "MIT",
2794
+
"optional": true,
2795
+
"os": [
2796
+
"linux"
2797
+
],
2798
+
"engines": {
2799
+
"node": ">=18"
2800
+
}
2801
+
},
2802
+
"node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": {
2803
+
"version": "0.25.2",
2804
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz",
2805
+
"integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==",
2806
+
"cpu": [
2807
+
"arm64"
2808
+
],
2809
+
"dev": true,
2810
+
"license": "MIT",
2811
+
"optional": true,
2812
+
"os": [
2813
+
"netbsd"
2814
+
],
2815
+
"engines": {
2816
+
"node": ">=18"
2817
+
}
2818
+
},
2819
+
"node_modules/wrangler/node_modules/@esbuild/netbsd-x64": {
2820
+
"version": "0.25.2",
2821
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz",
2822
+
"integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==",
2823
+
"cpu": [
2824
+
"x64"
2825
+
],
2826
+
"dev": true,
2827
+
"license": "MIT",
2828
+
"optional": true,
2829
+
"os": [
2830
+
"netbsd"
2831
+
],
2832
+
"engines": {
2833
+
"node": ">=18"
2834
+
}
2835
+
},
2836
+
"node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": {
2837
+
"version": "0.25.2",
2838
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz",
2839
+
"integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==",
2840
+
"cpu": [
2841
+
"arm64"
2842
+
],
2843
+
"dev": true,
2844
+
"license": "MIT",
2845
+
"optional": true,
2846
+
"os": [
2847
+
"openbsd"
2848
+
],
2849
+
"engines": {
2850
+
"node": ">=18"
2851
+
}
2852
+
},
2853
+
"node_modules/wrangler/node_modules/@esbuild/openbsd-x64": {
2854
+
"version": "0.25.2",
2855
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz",
2856
+
"integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==",
2857
+
"cpu": [
2858
+
"x64"
2859
+
],
2860
+
"dev": true,
2861
+
"license": "MIT",
2862
+
"optional": true,
2863
+
"os": [
2864
+
"openbsd"
2865
+
],
2866
+
"engines": {
2867
+
"node": ">=18"
2868
+
}
2869
+
},
2870
+
"node_modules/wrangler/node_modules/@esbuild/sunos-x64": {
2871
+
"version": "0.25.2",
2872
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz",
2873
+
"integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==",
2874
+
"cpu": [
2875
+
"x64"
2876
+
],
2877
+
"dev": true,
2878
+
"license": "MIT",
2879
+
"optional": true,
2880
+
"os": [
2881
+
"sunos"
2882
+
],
2883
+
"engines": {
2884
+
"node": ">=18"
2885
+
}
2886
+
},
2887
+
"node_modules/wrangler/node_modules/@esbuild/win32-arm64": {
2888
+
"version": "0.25.2",
2889
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz",
2890
+
"integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==",
2891
+
"cpu": [
2892
+
"arm64"
2893
+
],
2894
+
"dev": true,
2895
+
"license": "MIT",
2896
+
"optional": true,
2897
+
"os": [
2898
+
"win32"
2899
+
],
2900
+
"engines": {
2901
+
"node": ">=18"
2902
+
}
2903
+
},
2904
+
"node_modules/wrangler/node_modules/@esbuild/win32-ia32": {
2905
+
"version": "0.25.2",
2906
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz",
2907
+
"integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==",
2908
+
"cpu": [
2909
+
"ia32"
2910
+
],
2911
+
"dev": true,
2912
+
"license": "MIT",
2913
+
"optional": true,
2914
+
"os": [
2915
+
"win32"
2916
+
],
2917
+
"engines": {
2918
+
"node": ">=18"
2919
+
}
2920
+
},
2921
+
"node_modules/wrangler/node_modules/@esbuild/win32-x64": {
2922
+
"version": "0.25.2",
2923
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz",
2924
+
"integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==",
2925
+
"cpu": [
2926
+
"x64"
2927
+
],
2928
+
"dev": true,
2929
+
"license": "MIT",
2930
+
"optional": true,
2931
+
"os": [
2932
+
"win32"
2933
+
],
2934
+
"engines": {
2935
+
"node": ">=18"
2936
+
}
2937
+
},
2938
+
"node_modules/wrangler/node_modules/esbuild": {
2939
+
"version": "0.25.2",
2940
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz",
2941
+
"integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==",
2942
+
"dev": true,
2943
+
"hasInstallScript": true,
2944
+
"license": "MIT",
2945
+
"bin": {
2946
+
"esbuild": "bin/esbuild"
2947
+
},
2948
+
"engines": {
2949
+
"node": ">=18"
2950
+
},
2951
+
"optionalDependencies": {
2952
+
"@esbuild/aix-ppc64": "0.25.2",
2953
+
"@esbuild/android-arm": "0.25.2",
2954
+
"@esbuild/android-arm64": "0.25.2",
2955
+
"@esbuild/android-x64": "0.25.2",
2956
+
"@esbuild/darwin-arm64": "0.25.2",
2957
+
"@esbuild/darwin-x64": "0.25.2",
2958
+
"@esbuild/freebsd-arm64": "0.25.2",
2959
+
"@esbuild/freebsd-x64": "0.25.2",
2960
+
"@esbuild/linux-arm": "0.25.2",
2961
+
"@esbuild/linux-arm64": "0.25.2",
2962
+
"@esbuild/linux-ia32": "0.25.2",
2963
+
"@esbuild/linux-loong64": "0.25.2",
2964
+
"@esbuild/linux-mips64el": "0.25.2",
2965
+
"@esbuild/linux-ppc64": "0.25.2",
2966
+
"@esbuild/linux-riscv64": "0.25.2",
2967
+
"@esbuild/linux-s390x": "0.25.2",
2968
+
"@esbuild/linux-x64": "0.25.2",
2969
+
"@esbuild/netbsd-arm64": "0.25.2",
2970
+
"@esbuild/netbsd-x64": "0.25.2",
2971
+
"@esbuild/openbsd-arm64": "0.25.2",
2972
+
"@esbuild/openbsd-x64": "0.25.2",
2973
+
"@esbuild/sunos-x64": "0.25.2",
2974
+
"@esbuild/win32-arm64": "0.25.2",
2975
+
"@esbuild/win32-ia32": "0.25.2",
2976
+
"@esbuild/win32-x64": "0.25.2"
2977
+
}
2978
+
},
2979
+
"node_modules/ws": {
2980
+
"version": "8.18.0",
2981
+
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
2982
+
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
2983
+
"dev": true,
2984
+
"license": "MIT",
2985
+
"engines": {
2986
+
"node": ">=10.0.0"
2987
+
},
2988
+
"peerDependencies": {
2989
+
"bufferutil": "^4.0.1",
2990
+
"utf-8-validate": ">=5.0.2"
2991
+
},
2992
+
"peerDependenciesMeta": {
2993
+
"bufferutil": {
2994
+
"optional": true
2995
+
},
2996
+
"utf-8-validate": {
2997
+
"optional": true
2998
+
}
2999
+
}
3000
+
},
3001
+
"node_modules/youch": {
3002
+
"version": "3.3.4",
3003
+
"resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz",
3004
+
"integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==",
3005
+
"dev": true,
3006
+
"license": "MIT",
3007
+
"dependencies": {
3008
+
"cookie": "^0.7.1",
3009
+
"mustache": "^4.2.0",
3010
+
"stacktracey": "^2.1.8"
3011
+
}
3012
+
},
3013
+
"node_modules/zod": {
3014
+
"version": "3.24.3",
3015
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz",
3016
+
"integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==",
3017
+
"dev": true,
3018
+
"license": "MIT",
3019
+
"funding": {
3020
+
"url": "https://github.com/sponsors/colinhacks"
3021
+
}
3022
+
}
3023
+
}
3024
+
}
+16
avatar/package.json
+16
avatar/package.json
···
···
1
+
{
2
+
"name": "avatar",
3
+
"version": "0.0.0",
4
+
"private": true,
5
+
"scripts": {
6
+
"deploy": "wrangler deploy",
7
+
"dev": "wrangler dev",
8
+
"start": "wrangler dev",
9
+
"test": "vitest"
10
+
},
11
+
"devDependencies": {
12
+
"@cloudflare/vitest-pool-workers": "^0.8.19",
13
+
"vitest": "~3.0.7",
14
+
"wrangler": "^4.14.1"
15
+
}
16
+
}
+11
avatar/readme.md
+11
avatar/readme.md
···
···
1
+
# avatar
2
+
3
+
avatar is a small service that fetches your pretty Bluesky avatar and caches it on Cloudflare.
4
+
It uses a shared secret `AVATAR_SHARED_SECRET` to ensure requests only originate from the trusted appview.
5
+
6
+
It's deployed using `wrangler` like so:
7
+
8
+
```
9
+
npx wrangler deploy
10
+
npx wrangler secrets put AVATAR_SHARED_SECRET
11
+
```
+88
avatar/src/index.js
+88
avatar/src/index.js
···
···
1
+
export default {
2
+
async fetch(request, env) {
3
+
const url = new URL(request.url);
4
+
const { pathname } = url;
5
+
6
+
if (!pathname || pathname === '/') {
7
+
return new Response(`This is Tangled's avatar service. It fetches your pretty avatar from Bluesky and caches it on Cloudflare.
8
+
You can't use this directly unforunately since all requests are signed and may only originate from the appview.`);
9
+
}
10
+
11
+
const cache = caches.default;
12
+
13
+
let cacheKey = request.url;
14
+
let response = await cache.match(cacheKey);
15
+
if (response) {
16
+
return response;
17
+
}
18
+
19
+
const pathParts = pathname.slice(1).split('/');
20
+
if (pathParts.length < 2) {
21
+
return new Response('Bad URL', { status: 400 });
22
+
}
23
+
24
+
const [signatureHex, actor] = pathParts;
25
+
26
+
const actorBytes = new TextEncoder().encode(actor);
27
+
28
+
const key = await crypto.subtle.importKey(
29
+
'raw',
30
+
new TextEncoder().encode(env.AVATAR_SHARED_SECRET),
31
+
{ name: 'HMAC', hash: 'SHA-256' },
32
+
false,
33
+
['sign', 'verify'],
34
+
);
35
+
36
+
const computedSigBuffer = await crypto.subtle.sign('HMAC', key, actorBytes);
37
+
const computedSig = Array.from(new Uint8Array(computedSigBuffer))
38
+
.map((b) => b.toString(16).padStart(2, '0'))
39
+
.join('');
40
+
41
+
console.log({
42
+
level: 'debug',
43
+
message: 'avatar request for: ' + actor,
44
+
computedSignature: computedSig,
45
+
providedSignature: signatureHex,
46
+
});
47
+
48
+
const sigBytes = Uint8Array.from(signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)));
49
+
const valid = await crypto.subtle.verify('HMAC', key, sigBytes, actorBytes);
50
+
51
+
if (!valid) {
52
+
return new Response('Invalid signature', { status: 403 });
53
+
}
54
+
55
+
try {
56
+
const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`, { method: 'GET' });
57
+
const profile = await profileResponse.json();
58
+
const avatar = profile.avatar;
59
+
60
+
if (!avatar) {
61
+
return new Response(`avatar not found for ${actor}.`, { status: 404 });
62
+
}
63
+
64
+
// fetch the actual avatar image
65
+
const avatarResponse = await fetch(avatar);
66
+
if (!avatarResponse.ok) {
67
+
return new Response(`failed to fetch avatar for ${actor}.`, { status: avatarResponse.status });
68
+
}
69
+
70
+
const avatarData = await avatarResponse.arrayBuffer();
71
+
const contentType = avatarResponse.headers.get('content-type') || 'image/jpeg';
72
+
73
+
response = new Response(avatarData, {
74
+
headers: {
75
+
'Content-Type': contentType,
76
+
'Cache-Control': 'public, max-age=3600',
77
+
},
78
+
});
79
+
80
+
// cache it in cf using request.url as the key
81
+
await cache.put(cacheKey, response.clone());
82
+
83
+
return response;
84
+
} catch (error) {
85
+
return new Response(`error fetching avatar: ${error.message}`, { status: 500 });
86
+
}
87
+
},
88
+
};
+15
avatar/wrangler.jsonc
+15
avatar/wrangler.jsonc
···
···
1
+
{
2
+
"$schema": "node_modules/wrangler/config-schema.json",
3
+
"name": "avatar",
4
+
"main": "src/index.js",
5
+
"compatibility_date": "2025-05-03",
6
+
"observability": {
7
+
"enabled": true,
8
+
},
9
+
"routes": [
10
+
{
11
+
"pattern": "avatar.tangled.sh",
12
+
"custom_domain": true,
13
+
},
14
+
],
15
+
}
+174
camo/.gitignore
+174
camo/.gitignore
···
···
1
+
# Logs
2
+
3
+
./test.sh
4
+
5
+
logs
6
+
_.log
7
+
npm-debug.log_
8
+
yarn-debug.log*
9
+
yarn-error.log*
10
+
lerna-debug.log*
11
+
.pnpm-debug.log*
12
+
13
+
# Diagnostic reports (https://nodejs.org/api/report.html)
14
+
15
+
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
16
+
17
+
# Runtime data
18
+
19
+
pids
20
+
_.pid
21
+
_.seed
22
+
\*.pid.lock
23
+
24
+
# Directory for instrumented libs generated by jscoverage/JSCover
25
+
26
+
lib-cov
27
+
28
+
# Coverage directory used by tools like istanbul
29
+
30
+
coverage
31
+
\*.lcov
32
+
33
+
# nyc test coverage
34
+
35
+
.nyc_output
36
+
37
+
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
38
+
39
+
.grunt
40
+
41
+
# Bower dependency directory (https://bower.io/)
42
+
43
+
bower_components
44
+
45
+
# node-waf configuration
46
+
47
+
.lock-wscript
48
+
49
+
# Compiled binary addons (https://nodejs.org/api/addons.html)
50
+
51
+
build/Release
52
+
53
+
# Dependency directories
54
+
55
+
node_modules/
56
+
jspm_packages/
57
+
58
+
# Snowpack dependency directory (https://snowpack.dev/)
59
+
60
+
web_modules/
61
+
62
+
# TypeScript cache
63
+
64
+
\*.tsbuildinfo
65
+
66
+
# Optional npm cache directory
67
+
68
+
.npm
69
+
70
+
# Optional eslint cache
71
+
72
+
.eslintcache
73
+
74
+
# Optional stylelint cache
75
+
76
+
.stylelintcache
77
+
78
+
# Microbundle cache
79
+
80
+
.rpt2_cache/
81
+
.rts2_cache_cjs/
82
+
.rts2_cache_es/
83
+
.rts2_cache_umd/
84
+
85
+
# Optional REPL history
86
+
87
+
.node_repl_history
88
+
89
+
# Output of 'npm pack'
90
+
91
+
\*.tgz
92
+
93
+
# Yarn Integrity file
94
+
95
+
.yarn-integrity
96
+
97
+
# dotenv environment variable files
98
+
99
+
.env
100
+
.env.development.local
101
+
.env.test.local
102
+
.env.production.local
103
+
.env.local
104
+
105
+
# parcel-bundler cache (https://parceljs.org/)
106
+
107
+
.cache
108
+
.parcel-cache
109
+
110
+
# Next.js build output
111
+
112
+
.next
113
+
out
114
+
115
+
# Nuxt.js build / generate output
116
+
117
+
.nuxt
118
+
dist
119
+
120
+
# Gatsby files
121
+
122
+
.cache/
123
+
124
+
# Comment in the public line in if your project uses Gatsby and not Next.js
125
+
126
+
# https://nextjs.org/blog/next-9-1#public-directory-support
127
+
128
+
# public
129
+
130
+
# vuepress build output
131
+
132
+
.vuepress/dist
133
+
134
+
# vuepress v2.x temp and cache directory
135
+
136
+
.temp
137
+
.cache
138
+
139
+
# Docusaurus cache and generated files
140
+
141
+
.docusaurus
142
+
143
+
# Serverless directories
144
+
145
+
.serverless/
146
+
147
+
# FuseBox cache
148
+
149
+
.fusebox/
150
+
151
+
# DynamoDB Local files
152
+
153
+
.dynamodb/
154
+
155
+
# TernJS port file
156
+
157
+
.tern-port
158
+
159
+
# Stores VSCode versions used for testing VSCode extensions
160
+
161
+
.vscode-test
162
+
163
+
# yarn v2
164
+
165
+
.yarn/cache
166
+
.yarn/unplugged
167
+
.yarn/build-state.yml
168
+
.yarn/install-state.gz
169
+
.pnp.\*
170
+
171
+
# wrangler project
172
+
173
+
.dev.vars
174
+
.wrangler/
+3024
camo/package-lock.json
+3024
camo/package-lock.json
···
···
1
+
{
2
+
"name": "camo",
3
+
"version": "0.0.0",
4
+
"lockfileVersion": 3,
5
+
"requires": true,
6
+
"packages": {
7
+
"": {
8
+
"name": "camo",
9
+
"version": "0.0.0",
10
+
"devDependencies": {
11
+
"@cloudflare/vitest-pool-workers": "^0.8.19",
12
+
"vitest": "~3.0.7",
13
+
"wrangler": "^4.14.1"
14
+
}
15
+
},
16
+
"node_modules/@cloudflare/kv-asset-handler": {
17
+
"version": "0.4.0",
18
+
"resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz",
19
+
"integrity": "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==",
20
+
"dev": true,
21
+
"license": "MIT OR Apache-2.0",
22
+
"dependencies": {
23
+
"mime": "^3.0.0"
24
+
},
25
+
"engines": {
26
+
"node": ">=18.0.0"
27
+
}
28
+
},
29
+
"node_modules/@cloudflare/unenv-preset": {
30
+
"version": "2.3.1",
31
+
"resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.3.1.tgz",
32
+
"integrity": "sha512-Xq57Qd+ADpt6hibcVBO0uLG9zzRgyRhfCUgBT9s+g3+3Ivg5zDyVgLFy40ES1VdNcu8rPNSivm9A+kGP5IVaPg==",
33
+
"dev": true,
34
+
"license": "MIT OR Apache-2.0",
35
+
"peerDependencies": {
36
+
"unenv": "2.0.0-rc.15",
37
+
"workerd": "^1.20250320.0"
38
+
},
39
+
"peerDependenciesMeta": {
40
+
"workerd": {
41
+
"optional": true
42
+
}
43
+
}
44
+
},
45
+
"node_modules/@cloudflare/vitest-pool-workers": {
46
+
"version": "0.8.24",
47
+
"resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.8.24.tgz",
48
+
"integrity": "sha512-wT2PABJQ9YLYWrVu4CRZOjvmjHkdbMyLTZPU9n/7JEMM3pgG8dY41F1Rj31UsXRQaXX39A/CTPGlk58dcMUysA==",
49
+
"dev": true,
50
+
"license": "MIT",
51
+
"dependencies": {
52
+
"birpc": "0.2.14",
53
+
"cjs-module-lexer": "^1.2.3",
54
+
"devalue": "^4.3.0",
55
+
"miniflare": "4.20250428.1",
56
+
"semver": "^7.7.1",
57
+
"wrangler": "4.14.1",
58
+
"zod": "^3.22.3"
59
+
},
60
+
"peerDependencies": {
61
+
"@vitest/runner": "2.0.x - 3.1.x",
62
+
"@vitest/snapshot": "2.0.x - 3.1.x",
63
+
"vitest": "2.0.x - 3.1.x"
64
+
}
65
+
},
66
+
"node_modules/@cloudflare/workerd-darwin-64": {
67
+
"version": "1.20250428.0",
68
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250428.0.tgz",
69
+
"integrity": "sha512-6nVe9oV4Hdec6ctzMtW80TiDvNTd2oFPi3VsKqSDVaJSJbL+4b6seyJ7G/UEPI+si6JhHBSLV2/9lNXNGLjClA==",
70
+
"cpu": [
71
+
"x64"
72
+
],
73
+
"dev": true,
74
+
"license": "Apache-2.0",
75
+
"optional": true,
76
+
"os": [
77
+
"darwin"
78
+
],
79
+
"engines": {
80
+
"node": ">=16"
81
+
}
82
+
},
83
+
"node_modules/@cloudflare/workerd-darwin-arm64": {
84
+
"version": "1.20250428.0",
85
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250428.0.tgz",
86
+
"integrity": "sha512-/TB7bh7SIJ5f+6r4PHsAz7+9Qal/TK1cJuKFkUno1kqGlZbdrMwH0ATYwlWC/nBFeu2FB3NUolsTntEuy23hnQ==",
87
+
"cpu": [
88
+
"arm64"
89
+
],
90
+
"dev": true,
91
+
"license": "Apache-2.0",
92
+
"optional": true,
93
+
"os": [
94
+
"darwin"
95
+
],
96
+
"engines": {
97
+
"node": ">=16"
98
+
}
99
+
},
100
+
"node_modules/@cloudflare/workerd-linux-64": {
101
+
"version": "1.20250428.0",
102
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250428.0.tgz",
103
+
"integrity": "sha512-9eCbj+R3CKqpiXP6DfAA20DxKge+OTj7Hyw3ZewiEhWH9INIHiJwJQYybu4iq9kJEGjnGvxgguLFjSCWm26hgg==",
104
+
"cpu": [
105
+
"x64"
106
+
],
107
+
"dev": true,
108
+
"license": "Apache-2.0",
109
+
"optional": true,
110
+
"os": [
111
+
"linux"
112
+
],
113
+
"engines": {
114
+
"node": ">=16"
115
+
}
116
+
},
117
+
"node_modules/@cloudflare/workerd-linux-arm64": {
118
+
"version": "1.20250428.0",
119
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250428.0.tgz",
120
+
"integrity": "sha512-D9NRBnW46nl1EQsP13qfkYb5lbt4C6nxl38SBKY/NOcZAUoHzNB5K0GaK8LxvpkM7X/97ySojlMfR5jh5DNXYQ==",
121
+
"cpu": [
122
+
"arm64"
123
+
],
124
+
"dev": true,
125
+
"license": "Apache-2.0",
126
+
"optional": true,
127
+
"os": [
128
+
"linux"
129
+
],
130
+
"engines": {
131
+
"node": ">=16"
132
+
}
133
+
},
134
+
"node_modules/@cloudflare/workerd-windows-64": {
135
+
"version": "1.20250428.0",
136
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250428.0.tgz",
137
+
"integrity": "sha512-RQCRj28eitjKD0tmei6iFOuWqMuHMHdNGEigRmbkmuTlpbWHNAoHikgCzZQ/dkKDdatA76TmcpbyECNf31oaTA==",
138
+
"cpu": [
139
+
"x64"
140
+
],
141
+
"dev": true,
142
+
"license": "Apache-2.0",
143
+
"optional": true,
144
+
"os": [
145
+
"win32"
146
+
],
147
+
"engines": {
148
+
"node": ">=16"
149
+
}
150
+
},
151
+
"node_modules/@cspotcode/source-map-support": {
152
+
"version": "0.8.1",
153
+
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
154
+
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
155
+
"dev": true,
156
+
"license": "MIT",
157
+
"dependencies": {
158
+
"@jridgewell/trace-mapping": "0.3.9"
159
+
},
160
+
"engines": {
161
+
"node": ">=12"
162
+
}
163
+
},
164
+
"node_modules/@emnapi/runtime": {
165
+
"version": "1.4.3",
166
+
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
167
+
"integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
168
+
"dev": true,
169
+
"license": "MIT",
170
+
"optional": true,
171
+
"dependencies": {
172
+
"tslib": "^2.4.0"
173
+
}
174
+
},
175
+
"node_modules/@esbuild/aix-ppc64": {
176
+
"version": "0.25.3",
177
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz",
178
+
"integrity": "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==",
179
+
"cpu": [
180
+
"ppc64"
181
+
],
182
+
"dev": true,
183
+
"license": "MIT",
184
+
"optional": true,
185
+
"os": [
186
+
"aix"
187
+
],
188
+
"engines": {
189
+
"node": ">=18"
190
+
}
191
+
},
192
+
"node_modules/@esbuild/android-arm": {
193
+
"version": "0.25.3",
194
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz",
195
+
"integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==",
196
+
"cpu": [
197
+
"arm"
198
+
],
199
+
"dev": true,
200
+
"license": "MIT",
201
+
"optional": true,
202
+
"os": [
203
+
"android"
204
+
],
205
+
"engines": {
206
+
"node": ">=18"
207
+
}
208
+
},
209
+
"node_modules/@esbuild/android-arm64": {
210
+
"version": "0.25.3",
211
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz",
212
+
"integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==",
213
+
"cpu": [
214
+
"arm64"
215
+
],
216
+
"dev": true,
217
+
"license": "MIT",
218
+
"optional": true,
219
+
"os": [
220
+
"android"
221
+
],
222
+
"engines": {
223
+
"node": ">=18"
224
+
}
225
+
},
226
+
"node_modules/@esbuild/android-x64": {
227
+
"version": "0.25.3",
228
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.3.tgz",
229
+
"integrity": "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==",
230
+
"cpu": [
231
+
"x64"
232
+
],
233
+
"dev": true,
234
+
"license": "MIT",
235
+
"optional": true,
236
+
"os": [
237
+
"android"
238
+
],
239
+
"engines": {
240
+
"node": ">=18"
241
+
}
242
+
},
243
+
"node_modules/@esbuild/darwin-arm64": {
244
+
"version": "0.25.3",
245
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz",
246
+
"integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==",
247
+
"cpu": [
248
+
"arm64"
249
+
],
250
+
"dev": true,
251
+
"license": "MIT",
252
+
"optional": true,
253
+
"os": [
254
+
"darwin"
255
+
],
256
+
"engines": {
257
+
"node": ">=18"
258
+
}
259
+
},
260
+
"node_modules/@esbuild/darwin-x64": {
261
+
"version": "0.25.3",
262
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz",
263
+
"integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==",
264
+
"cpu": [
265
+
"x64"
266
+
],
267
+
"dev": true,
268
+
"license": "MIT",
269
+
"optional": true,
270
+
"os": [
271
+
"darwin"
272
+
],
273
+
"engines": {
274
+
"node": ">=18"
275
+
}
276
+
},
277
+
"node_modules/@esbuild/freebsd-arm64": {
278
+
"version": "0.25.3",
279
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz",
280
+
"integrity": "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==",
281
+
"cpu": [
282
+
"arm64"
283
+
],
284
+
"dev": true,
285
+
"license": "MIT",
286
+
"optional": true,
287
+
"os": [
288
+
"freebsd"
289
+
],
290
+
"engines": {
291
+
"node": ">=18"
292
+
}
293
+
},
294
+
"node_modules/@esbuild/freebsd-x64": {
295
+
"version": "0.25.3",
296
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz",
297
+
"integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==",
298
+
"cpu": [
299
+
"x64"
300
+
],
301
+
"dev": true,
302
+
"license": "MIT",
303
+
"optional": true,
304
+
"os": [
305
+
"freebsd"
306
+
],
307
+
"engines": {
308
+
"node": ">=18"
309
+
}
310
+
},
311
+
"node_modules/@esbuild/linux-arm": {
312
+
"version": "0.25.3",
313
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz",
314
+
"integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==",
315
+
"cpu": [
316
+
"arm"
317
+
],
318
+
"dev": true,
319
+
"license": "MIT",
320
+
"optional": true,
321
+
"os": [
322
+
"linux"
323
+
],
324
+
"engines": {
325
+
"node": ">=18"
326
+
}
327
+
},
328
+
"node_modules/@esbuild/linux-arm64": {
329
+
"version": "0.25.3",
330
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz",
331
+
"integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==",
332
+
"cpu": [
333
+
"arm64"
334
+
],
335
+
"dev": true,
336
+
"license": "MIT",
337
+
"optional": true,
338
+
"os": [
339
+
"linux"
340
+
],
341
+
"engines": {
342
+
"node": ">=18"
343
+
}
344
+
},
345
+
"node_modules/@esbuild/linux-ia32": {
346
+
"version": "0.25.3",
347
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz",
348
+
"integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==",
349
+
"cpu": [
350
+
"ia32"
351
+
],
352
+
"dev": true,
353
+
"license": "MIT",
354
+
"optional": true,
355
+
"os": [
356
+
"linux"
357
+
],
358
+
"engines": {
359
+
"node": ">=18"
360
+
}
361
+
},
362
+
"node_modules/@esbuild/linux-loong64": {
363
+
"version": "0.25.3",
364
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz",
365
+
"integrity": "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==",
366
+
"cpu": [
367
+
"loong64"
368
+
],
369
+
"dev": true,
370
+
"license": "MIT",
371
+
"optional": true,
372
+
"os": [
373
+
"linux"
374
+
],
375
+
"engines": {
376
+
"node": ">=18"
377
+
}
378
+
},
379
+
"node_modules/@esbuild/linux-mips64el": {
380
+
"version": "0.25.3",
381
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz",
382
+
"integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==",
383
+
"cpu": [
384
+
"mips64el"
385
+
],
386
+
"dev": true,
387
+
"license": "MIT",
388
+
"optional": true,
389
+
"os": [
390
+
"linux"
391
+
],
392
+
"engines": {
393
+
"node": ">=18"
394
+
}
395
+
},
396
+
"node_modules/@esbuild/linux-ppc64": {
397
+
"version": "0.25.3",
398
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz",
399
+
"integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==",
400
+
"cpu": [
401
+
"ppc64"
402
+
],
403
+
"dev": true,
404
+
"license": "MIT",
405
+
"optional": true,
406
+
"os": [
407
+
"linux"
408
+
],
409
+
"engines": {
410
+
"node": ">=18"
411
+
}
412
+
},
413
+
"node_modules/@esbuild/linux-riscv64": {
414
+
"version": "0.25.3",
415
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz",
416
+
"integrity": "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==",
417
+
"cpu": [
418
+
"riscv64"
419
+
],
420
+
"dev": true,
421
+
"license": "MIT",
422
+
"optional": true,
423
+
"os": [
424
+
"linux"
425
+
],
426
+
"engines": {
427
+
"node": ">=18"
428
+
}
429
+
},
430
+
"node_modules/@esbuild/linux-s390x": {
431
+
"version": "0.25.3",
432
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz",
433
+
"integrity": "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==",
434
+
"cpu": [
435
+
"s390x"
436
+
],
437
+
"dev": true,
438
+
"license": "MIT",
439
+
"optional": true,
440
+
"os": [
441
+
"linux"
442
+
],
443
+
"engines": {
444
+
"node": ">=18"
445
+
}
446
+
},
447
+
"node_modules/@esbuild/linux-x64": {
448
+
"version": "0.25.3",
449
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz",
450
+
"integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==",
451
+
"cpu": [
452
+
"x64"
453
+
],
454
+
"dev": true,
455
+
"license": "MIT",
456
+
"optional": true,
457
+
"os": [
458
+
"linux"
459
+
],
460
+
"engines": {
461
+
"node": ">=18"
462
+
}
463
+
},
464
+
"node_modules/@esbuild/netbsd-arm64": {
465
+
"version": "0.25.3",
466
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz",
467
+
"integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==",
468
+
"cpu": [
469
+
"arm64"
470
+
],
471
+
"dev": true,
472
+
"license": "MIT",
473
+
"optional": true,
474
+
"os": [
475
+
"netbsd"
476
+
],
477
+
"engines": {
478
+
"node": ">=18"
479
+
}
480
+
},
481
+
"node_modules/@esbuild/netbsd-x64": {
482
+
"version": "0.25.3",
483
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz",
484
+
"integrity": "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==",
485
+
"cpu": [
486
+
"x64"
487
+
],
488
+
"dev": true,
489
+
"license": "MIT",
490
+
"optional": true,
491
+
"os": [
492
+
"netbsd"
493
+
],
494
+
"engines": {
495
+
"node": ">=18"
496
+
}
497
+
},
498
+
"node_modules/@esbuild/openbsd-arm64": {
499
+
"version": "0.25.3",
500
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz",
501
+
"integrity": "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==",
502
+
"cpu": [
503
+
"arm64"
504
+
],
505
+
"dev": true,
506
+
"license": "MIT",
507
+
"optional": true,
508
+
"os": [
509
+
"openbsd"
510
+
],
511
+
"engines": {
512
+
"node": ">=18"
513
+
}
514
+
},
515
+
"node_modules/@esbuild/openbsd-x64": {
516
+
"version": "0.25.3",
517
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz",
518
+
"integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==",
519
+
"cpu": [
520
+
"x64"
521
+
],
522
+
"dev": true,
523
+
"license": "MIT",
524
+
"optional": true,
525
+
"os": [
526
+
"openbsd"
527
+
],
528
+
"engines": {
529
+
"node": ">=18"
530
+
}
531
+
},
532
+
"node_modules/@esbuild/sunos-x64": {
533
+
"version": "0.25.3",
534
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz",
535
+
"integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==",
536
+
"cpu": [
537
+
"x64"
538
+
],
539
+
"dev": true,
540
+
"license": "MIT",
541
+
"optional": true,
542
+
"os": [
543
+
"sunos"
544
+
],
545
+
"engines": {
546
+
"node": ">=18"
547
+
}
548
+
},
549
+
"node_modules/@esbuild/win32-arm64": {
550
+
"version": "0.25.3",
551
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz",
552
+
"integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==",
553
+
"cpu": [
554
+
"arm64"
555
+
],
556
+
"dev": true,
557
+
"license": "MIT",
558
+
"optional": true,
559
+
"os": [
560
+
"win32"
561
+
],
562
+
"engines": {
563
+
"node": ">=18"
564
+
}
565
+
},
566
+
"node_modules/@esbuild/win32-ia32": {
567
+
"version": "0.25.3",
568
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz",
569
+
"integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==",
570
+
"cpu": [
571
+
"ia32"
572
+
],
573
+
"dev": true,
574
+
"license": "MIT",
575
+
"optional": true,
576
+
"os": [
577
+
"win32"
578
+
],
579
+
"engines": {
580
+
"node": ">=18"
581
+
}
582
+
},
583
+
"node_modules/@esbuild/win32-x64": {
584
+
"version": "0.25.3",
585
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz",
586
+
"integrity": "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==",
587
+
"cpu": [
588
+
"x64"
589
+
],
590
+
"dev": true,
591
+
"license": "MIT",
592
+
"optional": true,
593
+
"os": [
594
+
"win32"
595
+
],
596
+
"engines": {
597
+
"node": ">=18"
598
+
}
599
+
},
600
+
"node_modules/@fastify/busboy": {
601
+
"version": "2.1.1",
602
+
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
603
+
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
604
+
"dev": true,
605
+
"license": "MIT",
606
+
"engines": {
607
+
"node": ">=14"
608
+
}
609
+
},
610
+
"node_modules/@img/sharp-darwin-arm64": {
611
+
"version": "0.33.5",
612
+
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
613
+
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
614
+
"cpu": [
615
+
"arm64"
616
+
],
617
+
"dev": true,
618
+
"license": "Apache-2.0",
619
+
"optional": true,
620
+
"os": [
621
+
"darwin"
622
+
],
623
+
"engines": {
624
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
625
+
},
626
+
"funding": {
627
+
"url": "https://opencollective.com/libvips"
628
+
},
629
+
"optionalDependencies": {
630
+
"@img/sharp-libvips-darwin-arm64": "1.0.4"
631
+
}
632
+
},
633
+
"node_modules/@img/sharp-darwin-x64": {
634
+
"version": "0.33.5",
635
+
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
636
+
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
637
+
"cpu": [
638
+
"x64"
639
+
],
640
+
"dev": true,
641
+
"license": "Apache-2.0",
642
+
"optional": true,
643
+
"os": [
644
+
"darwin"
645
+
],
646
+
"engines": {
647
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
648
+
},
649
+
"funding": {
650
+
"url": "https://opencollective.com/libvips"
651
+
},
652
+
"optionalDependencies": {
653
+
"@img/sharp-libvips-darwin-x64": "1.0.4"
654
+
}
655
+
},
656
+
"node_modules/@img/sharp-libvips-darwin-arm64": {
657
+
"version": "1.0.4",
658
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
659
+
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
660
+
"cpu": [
661
+
"arm64"
662
+
],
663
+
"dev": true,
664
+
"license": "LGPL-3.0-or-later",
665
+
"optional": true,
666
+
"os": [
667
+
"darwin"
668
+
],
669
+
"funding": {
670
+
"url": "https://opencollective.com/libvips"
671
+
}
672
+
},
673
+
"node_modules/@img/sharp-libvips-darwin-x64": {
674
+
"version": "1.0.4",
675
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
676
+
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
677
+
"cpu": [
678
+
"x64"
679
+
],
680
+
"dev": true,
681
+
"license": "LGPL-3.0-or-later",
682
+
"optional": true,
683
+
"os": [
684
+
"darwin"
685
+
],
686
+
"funding": {
687
+
"url": "https://opencollective.com/libvips"
688
+
}
689
+
},
690
+
"node_modules/@img/sharp-libvips-linux-arm": {
691
+
"version": "1.0.5",
692
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
693
+
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
694
+
"cpu": [
695
+
"arm"
696
+
],
697
+
"dev": true,
698
+
"license": "LGPL-3.0-or-later",
699
+
"optional": true,
700
+
"os": [
701
+
"linux"
702
+
],
703
+
"funding": {
704
+
"url": "https://opencollective.com/libvips"
705
+
}
706
+
},
707
+
"node_modules/@img/sharp-libvips-linux-arm64": {
708
+
"version": "1.0.4",
709
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
710
+
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
711
+
"cpu": [
712
+
"arm64"
713
+
],
714
+
"dev": true,
715
+
"license": "LGPL-3.0-or-later",
716
+
"optional": true,
717
+
"os": [
718
+
"linux"
719
+
],
720
+
"funding": {
721
+
"url": "https://opencollective.com/libvips"
722
+
}
723
+
},
724
+
"node_modules/@img/sharp-libvips-linux-s390x": {
725
+
"version": "1.0.4",
726
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
727
+
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
728
+
"cpu": [
729
+
"s390x"
730
+
],
731
+
"dev": true,
732
+
"license": "LGPL-3.0-or-later",
733
+
"optional": true,
734
+
"os": [
735
+
"linux"
736
+
],
737
+
"funding": {
738
+
"url": "https://opencollective.com/libvips"
739
+
}
740
+
},
741
+
"node_modules/@img/sharp-libvips-linux-x64": {
742
+
"version": "1.0.4",
743
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
744
+
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
745
+
"cpu": [
746
+
"x64"
747
+
],
748
+
"dev": true,
749
+
"license": "LGPL-3.0-or-later",
750
+
"optional": true,
751
+
"os": [
752
+
"linux"
753
+
],
754
+
"funding": {
755
+
"url": "https://opencollective.com/libvips"
756
+
}
757
+
},
758
+
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
759
+
"version": "1.0.4",
760
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
761
+
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
762
+
"cpu": [
763
+
"arm64"
764
+
],
765
+
"dev": true,
766
+
"license": "LGPL-3.0-or-later",
767
+
"optional": true,
768
+
"os": [
769
+
"linux"
770
+
],
771
+
"funding": {
772
+
"url": "https://opencollective.com/libvips"
773
+
}
774
+
},
775
+
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
776
+
"version": "1.0.4",
777
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
778
+
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
779
+
"cpu": [
780
+
"x64"
781
+
],
782
+
"dev": true,
783
+
"license": "LGPL-3.0-or-later",
784
+
"optional": true,
785
+
"os": [
786
+
"linux"
787
+
],
788
+
"funding": {
789
+
"url": "https://opencollective.com/libvips"
790
+
}
791
+
},
792
+
"node_modules/@img/sharp-linux-arm": {
793
+
"version": "0.33.5",
794
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
795
+
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
796
+
"cpu": [
797
+
"arm"
798
+
],
799
+
"dev": true,
800
+
"license": "Apache-2.0",
801
+
"optional": true,
802
+
"os": [
803
+
"linux"
804
+
],
805
+
"engines": {
806
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
807
+
},
808
+
"funding": {
809
+
"url": "https://opencollective.com/libvips"
810
+
},
811
+
"optionalDependencies": {
812
+
"@img/sharp-libvips-linux-arm": "1.0.5"
813
+
}
814
+
},
815
+
"node_modules/@img/sharp-linux-arm64": {
816
+
"version": "0.33.5",
817
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
818
+
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
819
+
"cpu": [
820
+
"arm64"
821
+
],
822
+
"dev": true,
823
+
"license": "Apache-2.0",
824
+
"optional": true,
825
+
"os": [
826
+
"linux"
827
+
],
828
+
"engines": {
829
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
830
+
},
831
+
"funding": {
832
+
"url": "https://opencollective.com/libvips"
833
+
},
834
+
"optionalDependencies": {
835
+
"@img/sharp-libvips-linux-arm64": "1.0.4"
836
+
}
837
+
},
838
+
"node_modules/@img/sharp-linux-s390x": {
839
+
"version": "0.33.5",
840
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
841
+
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
842
+
"cpu": [
843
+
"s390x"
844
+
],
845
+
"dev": true,
846
+
"license": "Apache-2.0",
847
+
"optional": true,
848
+
"os": [
849
+
"linux"
850
+
],
851
+
"engines": {
852
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
853
+
},
854
+
"funding": {
855
+
"url": "https://opencollective.com/libvips"
856
+
},
857
+
"optionalDependencies": {
858
+
"@img/sharp-libvips-linux-s390x": "1.0.4"
859
+
}
860
+
},
861
+
"node_modules/@img/sharp-linux-x64": {
862
+
"version": "0.33.5",
863
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
864
+
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
865
+
"cpu": [
866
+
"x64"
867
+
],
868
+
"dev": true,
869
+
"license": "Apache-2.0",
870
+
"optional": true,
871
+
"os": [
872
+
"linux"
873
+
],
874
+
"engines": {
875
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
876
+
},
877
+
"funding": {
878
+
"url": "https://opencollective.com/libvips"
879
+
},
880
+
"optionalDependencies": {
881
+
"@img/sharp-libvips-linux-x64": "1.0.4"
882
+
}
883
+
},
884
+
"node_modules/@img/sharp-linuxmusl-arm64": {
885
+
"version": "0.33.5",
886
+
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
887
+
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
888
+
"cpu": [
889
+
"arm64"
890
+
],
891
+
"dev": true,
892
+
"license": "Apache-2.0",
893
+
"optional": true,
894
+
"os": [
895
+
"linux"
896
+
],
897
+
"engines": {
898
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
899
+
},
900
+
"funding": {
901
+
"url": "https://opencollective.com/libvips"
902
+
},
903
+
"optionalDependencies": {
904
+
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
905
+
}
906
+
},
907
+
"node_modules/@img/sharp-linuxmusl-x64": {
908
+
"version": "0.33.5",
909
+
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
910
+
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
911
+
"cpu": [
912
+
"x64"
913
+
],
914
+
"dev": true,
915
+
"license": "Apache-2.0",
916
+
"optional": true,
917
+
"os": [
918
+
"linux"
919
+
],
920
+
"engines": {
921
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
922
+
},
923
+
"funding": {
924
+
"url": "https://opencollective.com/libvips"
925
+
},
926
+
"optionalDependencies": {
927
+
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
928
+
}
929
+
},
930
+
"node_modules/@img/sharp-wasm32": {
931
+
"version": "0.33.5",
932
+
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
933
+
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
934
+
"cpu": [
935
+
"wasm32"
936
+
],
937
+
"dev": true,
938
+
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
939
+
"optional": true,
940
+
"dependencies": {
941
+
"@emnapi/runtime": "^1.2.0"
942
+
},
943
+
"engines": {
944
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
945
+
},
946
+
"funding": {
947
+
"url": "https://opencollective.com/libvips"
948
+
}
949
+
},
950
+
"node_modules/@img/sharp-win32-ia32": {
951
+
"version": "0.33.5",
952
+
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
953
+
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
954
+
"cpu": [
955
+
"ia32"
956
+
],
957
+
"dev": true,
958
+
"license": "Apache-2.0 AND LGPL-3.0-or-later",
959
+
"optional": true,
960
+
"os": [
961
+
"win32"
962
+
],
963
+
"engines": {
964
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
965
+
},
966
+
"funding": {
967
+
"url": "https://opencollective.com/libvips"
968
+
}
969
+
},
970
+
"node_modules/@img/sharp-win32-x64": {
971
+
"version": "0.33.5",
972
+
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
973
+
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
974
+
"cpu": [
975
+
"x64"
976
+
],
977
+
"dev": true,
978
+
"license": "Apache-2.0 AND LGPL-3.0-or-later",
979
+
"optional": true,
980
+
"os": [
981
+
"win32"
982
+
],
983
+
"engines": {
984
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
985
+
},
986
+
"funding": {
987
+
"url": "https://opencollective.com/libvips"
988
+
}
989
+
},
990
+
"node_modules/@jridgewell/resolve-uri": {
991
+
"version": "3.1.2",
992
+
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
993
+
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
994
+
"dev": true,
995
+
"license": "MIT",
996
+
"engines": {
997
+
"node": ">=6.0.0"
998
+
}
999
+
},
1000
+
"node_modules/@jridgewell/sourcemap-codec": {
1001
+
"version": "1.5.0",
1002
+
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
1003
+
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
1004
+
"dev": true,
1005
+
"license": "MIT"
1006
+
},
1007
+
"node_modules/@jridgewell/trace-mapping": {
1008
+
"version": "0.3.9",
1009
+
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
1010
+
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
1011
+
"dev": true,
1012
+
"license": "MIT",
1013
+
"dependencies": {
1014
+
"@jridgewell/resolve-uri": "^3.0.3",
1015
+
"@jridgewell/sourcemap-codec": "^1.4.10"
1016
+
}
1017
+
},
1018
+
"node_modules/@rollup/rollup-android-arm-eabi": {
1019
+
"version": "4.40.1",
1020
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz",
1021
+
"integrity": "sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==",
1022
+
"cpu": [
1023
+
"arm"
1024
+
],
1025
+
"dev": true,
1026
+
"license": "MIT",
1027
+
"optional": true,
1028
+
"os": [
1029
+
"android"
1030
+
]
1031
+
},
1032
+
"node_modules/@rollup/rollup-android-arm64": {
1033
+
"version": "4.40.1",
1034
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.1.tgz",
1035
+
"integrity": "sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==",
1036
+
"cpu": [
1037
+
"arm64"
1038
+
],
1039
+
"dev": true,
1040
+
"license": "MIT",
1041
+
"optional": true,
1042
+
"os": [
1043
+
"android"
1044
+
]
1045
+
},
1046
+
"node_modules/@rollup/rollup-darwin-arm64": {
1047
+
"version": "4.40.1",
1048
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.1.tgz",
1049
+
"integrity": "sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==",
1050
+
"cpu": [
1051
+
"arm64"
1052
+
],
1053
+
"dev": true,
1054
+
"license": "MIT",
1055
+
"optional": true,
1056
+
"os": [
1057
+
"darwin"
1058
+
]
1059
+
},
1060
+
"node_modules/@rollup/rollup-darwin-x64": {
1061
+
"version": "4.40.1",
1062
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.1.tgz",
1063
+
"integrity": "sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==",
1064
+
"cpu": [
1065
+
"x64"
1066
+
],
1067
+
"dev": true,
1068
+
"license": "MIT",
1069
+
"optional": true,
1070
+
"os": [
1071
+
"darwin"
1072
+
]
1073
+
},
1074
+
"node_modules/@rollup/rollup-freebsd-arm64": {
1075
+
"version": "4.40.1",
1076
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.1.tgz",
1077
+
"integrity": "sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==",
1078
+
"cpu": [
1079
+
"arm64"
1080
+
],
1081
+
"dev": true,
1082
+
"license": "MIT",
1083
+
"optional": true,
1084
+
"os": [
1085
+
"freebsd"
1086
+
]
1087
+
},
1088
+
"node_modules/@rollup/rollup-freebsd-x64": {
1089
+
"version": "4.40.1",
1090
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.1.tgz",
1091
+
"integrity": "sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==",
1092
+
"cpu": [
1093
+
"x64"
1094
+
],
1095
+
"dev": true,
1096
+
"license": "MIT",
1097
+
"optional": true,
1098
+
"os": [
1099
+
"freebsd"
1100
+
]
1101
+
},
1102
+
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
1103
+
"version": "4.40.1",
1104
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.1.tgz",
1105
+
"integrity": "sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==",
1106
+
"cpu": [
1107
+
"arm"
1108
+
],
1109
+
"dev": true,
1110
+
"license": "MIT",
1111
+
"optional": true,
1112
+
"os": [
1113
+
"linux"
1114
+
]
1115
+
},
1116
+
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
1117
+
"version": "4.40.1",
1118
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.1.tgz",
1119
+
"integrity": "sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==",
1120
+
"cpu": [
1121
+
"arm"
1122
+
],
1123
+
"dev": true,
1124
+
"license": "MIT",
1125
+
"optional": true,
1126
+
"os": [
1127
+
"linux"
1128
+
]
1129
+
},
1130
+
"node_modules/@rollup/rollup-linux-arm64-gnu": {
1131
+
"version": "4.40.1",
1132
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.1.tgz",
1133
+
"integrity": "sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==",
1134
+
"cpu": [
1135
+
"arm64"
1136
+
],
1137
+
"dev": true,
1138
+
"license": "MIT",
1139
+
"optional": true,
1140
+
"os": [
1141
+
"linux"
1142
+
]
1143
+
},
1144
+
"node_modules/@rollup/rollup-linux-arm64-musl": {
1145
+
"version": "4.40.1",
1146
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.1.tgz",
1147
+
"integrity": "sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==",
1148
+
"cpu": [
1149
+
"arm64"
1150
+
],
1151
+
"dev": true,
1152
+
"license": "MIT",
1153
+
"optional": true,
1154
+
"os": [
1155
+
"linux"
1156
+
]
1157
+
},
1158
+
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
1159
+
"version": "4.40.1",
1160
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.1.tgz",
1161
+
"integrity": "sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==",
1162
+
"cpu": [
1163
+
"loong64"
1164
+
],
1165
+
"dev": true,
1166
+
"license": "MIT",
1167
+
"optional": true,
1168
+
"os": [
1169
+
"linux"
1170
+
]
1171
+
},
1172
+
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
1173
+
"version": "4.40.1",
1174
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.1.tgz",
1175
+
"integrity": "sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==",
1176
+
"cpu": [
1177
+
"ppc64"
1178
+
],
1179
+
"dev": true,
1180
+
"license": "MIT",
1181
+
"optional": true,
1182
+
"os": [
1183
+
"linux"
1184
+
]
1185
+
},
1186
+
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
1187
+
"version": "4.40.1",
1188
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.1.tgz",
1189
+
"integrity": "sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==",
1190
+
"cpu": [
1191
+
"riscv64"
1192
+
],
1193
+
"dev": true,
1194
+
"license": "MIT",
1195
+
"optional": true,
1196
+
"os": [
1197
+
"linux"
1198
+
]
1199
+
},
1200
+
"node_modules/@rollup/rollup-linux-riscv64-musl": {
1201
+
"version": "4.40.1",
1202
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.1.tgz",
1203
+
"integrity": "sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==",
1204
+
"cpu": [
1205
+
"riscv64"
1206
+
],
1207
+
"dev": true,
1208
+
"license": "MIT",
1209
+
"optional": true,
1210
+
"os": [
1211
+
"linux"
1212
+
]
1213
+
},
1214
+
"node_modules/@rollup/rollup-linux-s390x-gnu": {
1215
+
"version": "4.40.1",
1216
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.1.tgz",
1217
+
"integrity": "sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==",
1218
+
"cpu": [
1219
+
"s390x"
1220
+
],
1221
+
"dev": true,
1222
+
"license": "MIT",
1223
+
"optional": true,
1224
+
"os": [
1225
+
"linux"
1226
+
]
1227
+
},
1228
+
"node_modules/@rollup/rollup-linux-x64-gnu": {
1229
+
"version": "4.40.1",
1230
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.1.tgz",
1231
+
"integrity": "sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==",
1232
+
"cpu": [
1233
+
"x64"
1234
+
],
1235
+
"dev": true,
1236
+
"license": "MIT",
1237
+
"optional": true,
1238
+
"os": [
1239
+
"linux"
1240
+
]
1241
+
},
1242
+
"node_modules/@rollup/rollup-linux-x64-musl": {
1243
+
"version": "4.40.1",
1244
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.1.tgz",
1245
+
"integrity": "sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==",
1246
+
"cpu": [
1247
+
"x64"
1248
+
],
1249
+
"dev": true,
1250
+
"license": "MIT",
1251
+
"optional": true,
1252
+
"os": [
1253
+
"linux"
1254
+
]
1255
+
},
1256
+
"node_modules/@rollup/rollup-win32-arm64-msvc": {
1257
+
"version": "4.40.1",
1258
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.1.tgz",
1259
+
"integrity": "sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==",
1260
+
"cpu": [
1261
+
"arm64"
1262
+
],
1263
+
"dev": true,
1264
+
"license": "MIT",
1265
+
"optional": true,
1266
+
"os": [
1267
+
"win32"
1268
+
]
1269
+
},
1270
+
"node_modules/@rollup/rollup-win32-ia32-msvc": {
1271
+
"version": "4.40.1",
1272
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.1.tgz",
1273
+
"integrity": "sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==",
1274
+
"cpu": [
1275
+
"ia32"
1276
+
],
1277
+
"dev": true,
1278
+
"license": "MIT",
1279
+
"optional": true,
1280
+
"os": [
1281
+
"win32"
1282
+
]
1283
+
},
1284
+
"node_modules/@rollup/rollup-win32-x64-msvc": {
1285
+
"version": "4.40.1",
1286
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.1.tgz",
1287
+
"integrity": "sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==",
1288
+
"cpu": [
1289
+
"x64"
1290
+
],
1291
+
"dev": true,
1292
+
"license": "MIT",
1293
+
"optional": true,
1294
+
"os": [
1295
+
"win32"
1296
+
]
1297
+
},
1298
+
"node_modules/@types/estree": {
1299
+
"version": "1.0.7",
1300
+
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
1301
+
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
1302
+
"dev": true,
1303
+
"license": "MIT"
1304
+
},
1305
+
"node_modules/@vitest/expect": {
1306
+
"version": "3.0.9",
1307
+
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.9.tgz",
1308
+
"integrity": "sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==",
1309
+
"dev": true,
1310
+
"license": "MIT",
1311
+
"dependencies": {
1312
+
"@vitest/spy": "3.0.9",
1313
+
"@vitest/utils": "3.0.9",
1314
+
"chai": "^5.2.0",
1315
+
"tinyrainbow": "^2.0.0"
1316
+
},
1317
+
"funding": {
1318
+
"url": "https://opencollective.com/vitest"
1319
+
}
1320
+
},
1321
+
"node_modules/@vitest/mocker": {
1322
+
"version": "3.0.9",
1323
+
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.9.tgz",
1324
+
"integrity": "sha512-ryERPIBOnvevAkTq+L1lD+DTFBRcjueL9lOUfXsLfwP92h4e+Heb+PjiqS3/OURWPtywfafK0kj++yDFjWUmrA==",
1325
+
"dev": true,
1326
+
"license": "MIT",
1327
+
"dependencies": {
1328
+
"@vitest/spy": "3.0.9",
1329
+
"estree-walker": "^3.0.3",
1330
+
"magic-string": "^0.30.17"
1331
+
},
1332
+
"funding": {
1333
+
"url": "https://opencollective.com/vitest"
1334
+
},
1335
+
"peerDependencies": {
1336
+
"msw": "^2.4.9",
1337
+
"vite": "^5.0.0 || ^6.0.0"
1338
+
},
1339
+
"peerDependenciesMeta": {
1340
+
"msw": {
1341
+
"optional": true
1342
+
},
1343
+
"vite": {
1344
+
"optional": true
1345
+
}
1346
+
}
1347
+
},
1348
+
"node_modules/@vitest/pretty-format": {
1349
+
"version": "3.1.2",
1350
+
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz",
1351
+
"integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==",
1352
+
"dev": true,
1353
+
"license": "MIT",
1354
+
"dependencies": {
1355
+
"tinyrainbow": "^2.0.0"
1356
+
},
1357
+
"funding": {
1358
+
"url": "https://opencollective.com/vitest"
1359
+
}
1360
+
},
1361
+
"node_modules/@vitest/runner": {
1362
+
"version": "3.0.9",
1363
+
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.9.tgz",
1364
+
"integrity": "sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==",
1365
+
"dev": true,
1366
+
"license": "MIT",
1367
+
"dependencies": {
1368
+
"@vitest/utils": "3.0.9",
1369
+
"pathe": "^2.0.3"
1370
+
},
1371
+
"funding": {
1372
+
"url": "https://opencollective.com/vitest"
1373
+
}
1374
+
},
1375
+
"node_modules/@vitest/snapshot": {
1376
+
"version": "3.0.9",
1377
+
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.9.tgz",
1378
+
"integrity": "sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==",
1379
+
"dev": true,
1380
+
"license": "MIT",
1381
+
"dependencies": {
1382
+
"@vitest/pretty-format": "3.0.9",
1383
+
"magic-string": "^0.30.17",
1384
+
"pathe": "^2.0.3"
1385
+
},
1386
+
"funding": {
1387
+
"url": "https://opencollective.com/vitest"
1388
+
}
1389
+
},
1390
+
"node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
1391
+
"version": "3.0.9",
1392
+
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.9.tgz",
1393
+
"integrity": "sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==",
1394
+
"dev": true,
1395
+
"license": "MIT",
1396
+
"dependencies": {
1397
+
"tinyrainbow": "^2.0.0"
1398
+
},
1399
+
"funding": {
1400
+
"url": "https://opencollective.com/vitest"
1401
+
}
1402
+
},
1403
+
"node_modules/@vitest/spy": {
1404
+
"version": "3.0.9",
1405
+
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.9.tgz",
1406
+
"integrity": "sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==",
1407
+
"dev": true,
1408
+
"license": "MIT",
1409
+
"dependencies": {
1410
+
"tinyspy": "^3.0.2"
1411
+
},
1412
+
"funding": {
1413
+
"url": "https://opencollective.com/vitest"
1414
+
}
1415
+
},
1416
+
"node_modules/@vitest/utils": {
1417
+
"version": "3.0.9",
1418
+
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.9.tgz",
1419
+
"integrity": "sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==",
1420
+
"dev": true,
1421
+
"license": "MIT",
1422
+
"dependencies": {
1423
+
"@vitest/pretty-format": "3.0.9",
1424
+
"loupe": "^3.1.3",
1425
+
"tinyrainbow": "^2.0.0"
1426
+
},
1427
+
"funding": {
1428
+
"url": "https://opencollective.com/vitest"
1429
+
}
1430
+
},
1431
+
"node_modules/@vitest/utils/node_modules/@vitest/pretty-format": {
1432
+
"version": "3.0.9",
1433
+
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.9.tgz",
1434
+
"integrity": "sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==",
1435
+
"dev": true,
1436
+
"license": "MIT",
1437
+
"dependencies": {
1438
+
"tinyrainbow": "^2.0.0"
1439
+
},
1440
+
"funding": {
1441
+
"url": "https://opencollective.com/vitest"
1442
+
}
1443
+
},
1444
+
"node_modules/acorn": {
1445
+
"version": "8.14.0",
1446
+
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
1447
+
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
1448
+
"dev": true,
1449
+
"license": "MIT",
1450
+
"bin": {
1451
+
"acorn": "bin/acorn"
1452
+
},
1453
+
"engines": {
1454
+
"node": ">=0.4.0"
1455
+
}
1456
+
},
1457
+
"node_modules/acorn-walk": {
1458
+
"version": "8.3.2",
1459
+
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
1460
+
"integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
1461
+
"dev": true,
1462
+
"license": "MIT",
1463
+
"engines": {
1464
+
"node": ">=0.4.0"
1465
+
}
1466
+
},
1467
+
"node_modules/as-table": {
1468
+
"version": "1.0.55",
1469
+
"resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz",
1470
+
"integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==",
1471
+
"dev": true,
1472
+
"license": "MIT",
1473
+
"dependencies": {
1474
+
"printable-characters": "^1.0.42"
1475
+
}
1476
+
},
1477
+
"node_modules/assertion-error": {
1478
+
"version": "2.0.1",
1479
+
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
1480
+
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
1481
+
"dev": true,
1482
+
"license": "MIT",
1483
+
"engines": {
1484
+
"node": ">=12"
1485
+
}
1486
+
},
1487
+
"node_modules/birpc": {
1488
+
"version": "0.2.14",
1489
+
"resolved": "https://registry.npmjs.org/birpc/-/birpc-0.2.14.tgz",
1490
+
"integrity": "sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA==",
1491
+
"dev": true,
1492
+
"license": "MIT",
1493
+
"funding": {
1494
+
"url": "https://github.com/sponsors/antfu"
1495
+
}
1496
+
},
1497
+
"node_modules/blake3-wasm": {
1498
+
"version": "2.1.5",
1499
+
"resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
1500
+
"integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
1501
+
"dev": true,
1502
+
"license": "MIT"
1503
+
},
1504
+
"node_modules/cac": {
1505
+
"version": "6.7.14",
1506
+
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
1507
+
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
1508
+
"dev": true,
1509
+
"license": "MIT",
1510
+
"engines": {
1511
+
"node": ">=8"
1512
+
}
1513
+
},
1514
+
"node_modules/chai": {
1515
+
"version": "5.2.0",
1516
+
"resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
1517
+
"integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
1518
+
"dev": true,
1519
+
"license": "MIT",
1520
+
"dependencies": {
1521
+
"assertion-error": "^2.0.1",
1522
+
"check-error": "^2.1.1",
1523
+
"deep-eql": "^5.0.1",
1524
+
"loupe": "^3.1.0",
1525
+
"pathval": "^2.0.0"
1526
+
},
1527
+
"engines": {
1528
+
"node": ">=12"
1529
+
}
1530
+
},
1531
+
"node_modules/check-error": {
1532
+
"version": "2.1.1",
1533
+
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
1534
+
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
1535
+
"dev": true,
1536
+
"license": "MIT",
1537
+
"engines": {
1538
+
"node": ">= 16"
1539
+
}
1540
+
},
1541
+
"node_modules/cjs-module-lexer": {
1542
+
"version": "1.4.3",
1543
+
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
1544
+
"integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
1545
+
"dev": true,
1546
+
"license": "MIT"
1547
+
},
1548
+
"node_modules/color": {
1549
+
"version": "4.2.3",
1550
+
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
1551
+
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
1552
+
"dev": true,
1553
+
"license": "MIT",
1554
+
"optional": true,
1555
+
"dependencies": {
1556
+
"color-convert": "^2.0.1",
1557
+
"color-string": "^1.9.0"
1558
+
},
1559
+
"engines": {
1560
+
"node": ">=12.5.0"
1561
+
}
1562
+
},
1563
+
"node_modules/color-convert": {
1564
+
"version": "2.0.1",
1565
+
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
1566
+
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
1567
+
"dev": true,
1568
+
"license": "MIT",
1569
+
"optional": true,
1570
+
"dependencies": {
1571
+
"color-name": "~1.1.4"
1572
+
},
1573
+
"engines": {
1574
+
"node": ">=7.0.0"
1575
+
}
1576
+
},
1577
+
"node_modules/color-name": {
1578
+
"version": "1.1.4",
1579
+
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
1580
+
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
1581
+
"dev": true,
1582
+
"license": "MIT",
1583
+
"optional": true
1584
+
},
1585
+
"node_modules/color-string": {
1586
+
"version": "1.9.1",
1587
+
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
1588
+
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
1589
+
"dev": true,
1590
+
"license": "MIT",
1591
+
"optional": true,
1592
+
"dependencies": {
1593
+
"color-name": "^1.0.0",
1594
+
"simple-swizzle": "^0.2.2"
1595
+
}
1596
+
},
1597
+
"node_modules/cookie": {
1598
+
"version": "0.7.2",
1599
+
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
1600
+
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
1601
+
"dev": true,
1602
+
"license": "MIT",
1603
+
"engines": {
1604
+
"node": ">= 0.6"
1605
+
}
1606
+
},
1607
+
"node_modules/data-uri-to-buffer": {
1608
+
"version": "2.0.2",
1609
+
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz",
1610
+
"integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==",
1611
+
"dev": true,
1612
+
"license": "MIT"
1613
+
},
1614
+
"node_modules/debug": {
1615
+
"version": "4.4.0",
1616
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
1617
+
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
1618
+
"dev": true,
1619
+
"license": "MIT",
1620
+
"dependencies": {
1621
+
"ms": "^2.1.3"
1622
+
},
1623
+
"engines": {
1624
+
"node": ">=6.0"
1625
+
},
1626
+
"peerDependenciesMeta": {
1627
+
"supports-color": {
1628
+
"optional": true
1629
+
}
1630
+
}
1631
+
},
1632
+
"node_modules/deep-eql": {
1633
+
"version": "5.0.2",
1634
+
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
1635
+
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
1636
+
"dev": true,
1637
+
"license": "MIT",
1638
+
"engines": {
1639
+
"node": ">=6"
1640
+
}
1641
+
},
1642
+
"node_modules/defu": {
1643
+
"version": "6.1.4",
1644
+
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
1645
+
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
1646
+
"dev": true,
1647
+
"license": "MIT"
1648
+
},
1649
+
"node_modules/detect-libc": {
1650
+
"version": "2.0.4",
1651
+
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
1652
+
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
1653
+
"dev": true,
1654
+
"license": "Apache-2.0",
1655
+
"optional": true,
1656
+
"engines": {
1657
+
"node": ">=8"
1658
+
}
1659
+
},
1660
+
"node_modules/devalue": {
1661
+
"version": "4.3.3",
1662
+
"resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.3.tgz",
1663
+
"integrity": "sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg==",
1664
+
"dev": true,
1665
+
"license": "MIT"
1666
+
},
1667
+
"node_modules/es-module-lexer": {
1668
+
"version": "1.7.0",
1669
+
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
1670
+
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
1671
+
"dev": true,
1672
+
"license": "MIT"
1673
+
},
1674
+
"node_modules/esbuild": {
1675
+
"version": "0.25.3",
1676
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz",
1677
+
"integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==",
1678
+
"dev": true,
1679
+
"hasInstallScript": true,
1680
+
"license": "MIT",
1681
+
"bin": {
1682
+
"esbuild": "bin/esbuild"
1683
+
},
1684
+
"engines": {
1685
+
"node": ">=18"
1686
+
},
1687
+
"optionalDependencies": {
1688
+
"@esbuild/aix-ppc64": "0.25.3",
1689
+
"@esbuild/android-arm": "0.25.3",
1690
+
"@esbuild/android-arm64": "0.25.3",
1691
+
"@esbuild/android-x64": "0.25.3",
1692
+
"@esbuild/darwin-arm64": "0.25.3",
1693
+
"@esbuild/darwin-x64": "0.25.3",
1694
+
"@esbuild/freebsd-arm64": "0.25.3",
1695
+
"@esbuild/freebsd-x64": "0.25.3",
1696
+
"@esbuild/linux-arm": "0.25.3",
1697
+
"@esbuild/linux-arm64": "0.25.3",
1698
+
"@esbuild/linux-ia32": "0.25.3",
1699
+
"@esbuild/linux-loong64": "0.25.3",
1700
+
"@esbuild/linux-mips64el": "0.25.3",
1701
+
"@esbuild/linux-ppc64": "0.25.3",
1702
+
"@esbuild/linux-riscv64": "0.25.3",
1703
+
"@esbuild/linux-s390x": "0.25.3",
1704
+
"@esbuild/linux-x64": "0.25.3",
1705
+
"@esbuild/netbsd-arm64": "0.25.3",
1706
+
"@esbuild/netbsd-x64": "0.25.3",
1707
+
"@esbuild/openbsd-arm64": "0.25.3",
1708
+
"@esbuild/openbsd-x64": "0.25.3",
1709
+
"@esbuild/sunos-x64": "0.25.3",
1710
+
"@esbuild/win32-arm64": "0.25.3",
1711
+
"@esbuild/win32-ia32": "0.25.3",
1712
+
"@esbuild/win32-x64": "0.25.3"
1713
+
}
1714
+
},
1715
+
"node_modules/estree-walker": {
1716
+
"version": "3.0.3",
1717
+
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
1718
+
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
1719
+
"dev": true,
1720
+
"license": "MIT",
1721
+
"dependencies": {
1722
+
"@types/estree": "^1.0.0"
1723
+
}
1724
+
},
1725
+
"node_modules/exit-hook": {
1726
+
"version": "2.2.1",
1727
+
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
1728
+
"integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==",
1729
+
"dev": true,
1730
+
"license": "MIT",
1731
+
"engines": {
1732
+
"node": ">=6"
1733
+
},
1734
+
"funding": {
1735
+
"url": "https://github.com/sponsors/sindresorhus"
1736
+
}
1737
+
},
1738
+
"node_modules/expect-type": {
1739
+
"version": "1.2.1",
1740
+
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz",
1741
+
"integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==",
1742
+
"dev": true,
1743
+
"license": "Apache-2.0",
1744
+
"engines": {
1745
+
"node": ">=12.0.0"
1746
+
}
1747
+
},
1748
+
"node_modules/exsolve": {
1749
+
"version": "1.0.5",
1750
+
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.5.tgz",
1751
+
"integrity": "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==",
1752
+
"dev": true,
1753
+
"license": "MIT"
1754
+
},
1755
+
"node_modules/fdir": {
1756
+
"version": "6.4.4",
1757
+
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
1758
+
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
1759
+
"dev": true,
1760
+
"license": "MIT",
1761
+
"peerDependencies": {
1762
+
"picomatch": "^3 || ^4"
1763
+
},
1764
+
"peerDependenciesMeta": {
1765
+
"picomatch": {
1766
+
"optional": true
1767
+
}
1768
+
}
1769
+
},
1770
+
"node_modules/fsevents": {
1771
+
"version": "2.3.3",
1772
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1773
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1774
+
"dev": true,
1775
+
"hasInstallScript": true,
1776
+
"license": "MIT",
1777
+
"optional": true,
1778
+
"os": [
1779
+
"darwin"
1780
+
],
1781
+
"engines": {
1782
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1783
+
}
1784
+
},
1785
+
"node_modules/get-source": {
1786
+
"version": "2.0.12",
1787
+
"resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz",
1788
+
"integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==",
1789
+
"dev": true,
1790
+
"license": "Unlicense",
1791
+
"dependencies": {
1792
+
"data-uri-to-buffer": "^2.0.0",
1793
+
"source-map": "^0.6.1"
1794
+
}
1795
+
},
1796
+
"node_modules/glob-to-regexp": {
1797
+
"version": "0.4.1",
1798
+
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
1799
+
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
1800
+
"dev": true,
1801
+
"license": "BSD-2-Clause"
1802
+
},
1803
+
"node_modules/is-arrayish": {
1804
+
"version": "0.3.2",
1805
+
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
1806
+
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
1807
+
"dev": true,
1808
+
"license": "MIT",
1809
+
"optional": true
1810
+
},
1811
+
"node_modules/loupe": {
1812
+
"version": "3.1.3",
1813
+
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
1814
+
"integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==",
1815
+
"dev": true,
1816
+
"license": "MIT"
1817
+
},
1818
+
"node_modules/magic-string": {
1819
+
"version": "0.30.17",
1820
+
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
1821
+
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
1822
+
"dev": true,
1823
+
"license": "MIT",
1824
+
"dependencies": {
1825
+
"@jridgewell/sourcemap-codec": "^1.5.0"
1826
+
}
1827
+
},
1828
+
"node_modules/mime": {
1829
+
"version": "3.0.0",
1830
+
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
1831
+
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
1832
+
"dev": true,
1833
+
"license": "MIT",
1834
+
"bin": {
1835
+
"mime": "cli.js"
1836
+
},
1837
+
"engines": {
1838
+
"node": ">=10.0.0"
1839
+
}
1840
+
},
1841
+
"node_modules/miniflare": {
1842
+
"version": "4.20250428.1",
1843
+
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250428.1.tgz",
1844
+
"integrity": "sha512-M3qcJXjeAEimHrEeWXEhrJiC3YHB5M3QSqqK67pOTI+lHn0QyVG/2iFUjVJ/nv+i10uxeAEva8GRGeu+tKRCmQ==",
1845
+
"dev": true,
1846
+
"license": "MIT",
1847
+
"dependencies": {
1848
+
"@cspotcode/source-map-support": "0.8.1",
1849
+
"acorn": "8.14.0",
1850
+
"acorn-walk": "8.3.2",
1851
+
"exit-hook": "2.2.1",
1852
+
"glob-to-regexp": "0.4.1",
1853
+
"stoppable": "1.1.0",
1854
+
"undici": "^5.28.5",
1855
+
"workerd": "1.20250428.0",
1856
+
"ws": "8.18.0",
1857
+
"youch": "3.3.4",
1858
+
"zod": "3.22.3"
1859
+
},
1860
+
"bin": {
1861
+
"miniflare": "bootstrap.js"
1862
+
},
1863
+
"engines": {
1864
+
"node": ">=18.0.0"
1865
+
}
1866
+
},
1867
+
"node_modules/miniflare/node_modules/zod": {
1868
+
"version": "3.22.3",
1869
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz",
1870
+
"integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==",
1871
+
"dev": true,
1872
+
"license": "MIT",
1873
+
"funding": {
1874
+
"url": "https://github.com/sponsors/colinhacks"
1875
+
}
1876
+
},
1877
+
"node_modules/ms": {
1878
+
"version": "2.1.3",
1879
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1880
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1881
+
"dev": true,
1882
+
"license": "MIT"
1883
+
},
1884
+
"node_modules/mustache": {
1885
+
"version": "4.2.0",
1886
+
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
1887
+
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
1888
+
"dev": true,
1889
+
"license": "MIT",
1890
+
"bin": {
1891
+
"mustache": "bin/mustache"
1892
+
}
1893
+
},
1894
+
"node_modules/nanoid": {
1895
+
"version": "3.3.11",
1896
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
1897
+
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
1898
+
"dev": true,
1899
+
"funding": [
1900
+
{
1901
+
"type": "github",
1902
+
"url": "https://github.com/sponsors/ai"
1903
+
}
1904
+
],
1905
+
"license": "MIT",
1906
+
"bin": {
1907
+
"nanoid": "bin/nanoid.cjs"
1908
+
},
1909
+
"engines": {
1910
+
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1911
+
}
1912
+
},
1913
+
"node_modules/ohash": {
1914
+
"version": "2.0.11",
1915
+
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
1916
+
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
1917
+
"dev": true,
1918
+
"license": "MIT"
1919
+
},
1920
+
"node_modules/path-to-regexp": {
1921
+
"version": "6.3.0",
1922
+
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
1923
+
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
1924
+
"dev": true,
1925
+
"license": "MIT"
1926
+
},
1927
+
"node_modules/pathe": {
1928
+
"version": "2.0.3",
1929
+
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
1930
+
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
1931
+
"dev": true,
1932
+
"license": "MIT"
1933
+
},
1934
+
"node_modules/pathval": {
1935
+
"version": "2.0.0",
1936
+
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
1937
+
"integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
1938
+
"dev": true,
1939
+
"license": "MIT",
1940
+
"engines": {
1941
+
"node": ">= 14.16"
1942
+
}
1943
+
},
1944
+
"node_modules/picocolors": {
1945
+
"version": "1.1.1",
1946
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1947
+
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1948
+
"dev": true,
1949
+
"license": "ISC"
1950
+
},
1951
+
"node_modules/picomatch": {
1952
+
"version": "4.0.2",
1953
+
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
1954
+
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
1955
+
"dev": true,
1956
+
"license": "MIT",
1957
+
"engines": {
1958
+
"node": ">=12"
1959
+
},
1960
+
"funding": {
1961
+
"url": "https://github.com/sponsors/jonschlinkert"
1962
+
}
1963
+
},
1964
+
"node_modules/postcss": {
1965
+
"version": "8.5.3",
1966
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
1967
+
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
1968
+
"dev": true,
1969
+
"funding": [
1970
+
{
1971
+
"type": "opencollective",
1972
+
"url": "https://opencollective.com/postcss/"
1973
+
},
1974
+
{
1975
+
"type": "tidelift",
1976
+
"url": "https://tidelift.com/funding/github/npm/postcss"
1977
+
},
1978
+
{
1979
+
"type": "github",
1980
+
"url": "https://github.com/sponsors/ai"
1981
+
}
1982
+
],
1983
+
"license": "MIT",
1984
+
"dependencies": {
1985
+
"nanoid": "^3.3.8",
1986
+
"picocolors": "^1.1.1",
1987
+
"source-map-js": "^1.2.1"
1988
+
},
1989
+
"engines": {
1990
+
"node": "^10 || ^12 || >=14"
1991
+
}
1992
+
},
1993
+
"node_modules/printable-characters": {
1994
+
"version": "1.0.42",
1995
+
"resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz",
1996
+
"integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==",
1997
+
"dev": true,
1998
+
"license": "Unlicense"
1999
+
},
2000
+
"node_modules/rollup": {
2001
+
"version": "4.40.1",
2002
+
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.1.tgz",
2003
+
"integrity": "sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==",
2004
+
"dev": true,
2005
+
"license": "MIT",
2006
+
"dependencies": {
2007
+
"@types/estree": "1.0.7"
2008
+
},
2009
+
"bin": {
2010
+
"rollup": "dist/bin/rollup"
2011
+
},
2012
+
"engines": {
2013
+
"node": ">=18.0.0",
2014
+
"npm": ">=8.0.0"
2015
+
},
2016
+
"optionalDependencies": {
2017
+
"@rollup/rollup-android-arm-eabi": "4.40.1",
2018
+
"@rollup/rollup-android-arm64": "4.40.1",
2019
+
"@rollup/rollup-darwin-arm64": "4.40.1",
2020
+
"@rollup/rollup-darwin-x64": "4.40.1",
2021
+
"@rollup/rollup-freebsd-arm64": "4.40.1",
2022
+
"@rollup/rollup-freebsd-x64": "4.40.1",
2023
+
"@rollup/rollup-linux-arm-gnueabihf": "4.40.1",
2024
+
"@rollup/rollup-linux-arm-musleabihf": "4.40.1",
2025
+
"@rollup/rollup-linux-arm64-gnu": "4.40.1",
2026
+
"@rollup/rollup-linux-arm64-musl": "4.40.1",
2027
+
"@rollup/rollup-linux-loongarch64-gnu": "4.40.1",
2028
+
"@rollup/rollup-linux-powerpc64le-gnu": "4.40.1",
2029
+
"@rollup/rollup-linux-riscv64-gnu": "4.40.1",
2030
+
"@rollup/rollup-linux-riscv64-musl": "4.40.1",
2031
+
"@rollup/rollup-linux-s390x-gnu": "4.40.1",
2032
+
"@rollup/rollup-linux-x64-gnu": "4.40.1",
2033
+
"@rollup/rollup-linux-x64-musl": "4.40.1",
2034
+
"@rollup/rollup-win32-arm64-msvc": "4.40.1",
2035
+
"@rollup/rollup-win32-ia32-msvc": "4.40.1",
2036
+
"@rollup/rollup-win32-x64-msvc": "4.40.1",
2037
+
"fsevents": "~2.3.2"
2038
+
}
2039
+
},
2040
+
"node_modules/semver": {
2041
+
"version": "7.7.1",
2042
+
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
2043
+
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
2044
+
"dev": true,
2045
+
"license": "ISC",
2046
+
"bin": {
2047
+
"semver": "bin/semver.js"
2048
+
},
2049
+
"engines": {
2050
+
"node": ">=10"
2051
+
}
2052
+
},
2053
+
"node_modules/sharp": {
2054
+
"version": "0.33.5",
2055
+
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
2056
+
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
2057
+
"dev": true,
2058
+
"hasInstallScript": true,
2059
+
"license": "Apache-2.0",
2060
+
"optional": true,
2061
+
"dependencies": {
2062
+
"color": "^4.2.3",
2063
+
"detect-libc": "^2.0.3",
2064
+
"semver": "^7.6.3"
2065
+
},
2066
+
"engines": {
2067
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
2068
+
},
2069
+
"funding": {
2070
+
"url": "https://opencollective.com/libvips"
2071
+
},
2072
+
"optionalDependencies": {
2073
+
"@img/sharp-darwin-arm64": "0.33.5",
2074
+
"@img/sharp-darwin-x64": "0.33.5",
2075
+
"@img/sharp-libvips-darwin-arm64": "1.0.4",
2076
+
"@img/sharp-libvips-darwin-x64": "1.0.4",
2077
+
"@img/sharp-libvips-linux-arm": "1.0.5",
2078
+
"@img/sharp-libvips-linux-arm64": "1.0.4",
2079
+
"@img/sharp-libvips-linux-s390x": "1.0.4",
2080
+
"@img/sharp-libvips-linux-x64": "1.0.4",
2081
+
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
2082
+
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
2083
+
"@img/sharp-linux-arm": "0.33.5",
2084
+
"@img/sharp-linux-arm64": "0.33.5",
2085
+
"@img/sharp-linux-s390x": "0.33.5",
2086
+
"@img/sharp-linux-x64": "0.33.5",
2087
+
"@img/sharp-linuxmusl-arm64": "0.33.5",
2088
+
"@img/sharp-linuxmusl-x64": "0.33.5",
2089
+
"@img/sharp-wasm32": "0.33.5",
2090
+
"@img/sharp-win32-ia32": "0.33.5",
2091
+
"@img/sharp-win32-x64": "0.33.5"
2092
+
}
2093
+
},
2094
+
"node_modules/siginfo": {
2095
+
"version": "2.0.0",
2096
+
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
2097
+
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
2098
+
"dev": true,
2099
+
"license": "ISC"
2100
+
},
2101
+
"node_modules/simple-swizzle": {
2102
+
"version": "0.2.2",
2103
+
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
2104
+
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
2105
+
"dev": true,
2106
+
"license": "MIT",
2107
+
"optional": true,
2108
+
"dependencies": {
2109
+
"is-arrayish": "^0.3.1"
2110
+
}
2111
+
},
2112
+
"node_modules/source-map": {
2113
+
"version": "0.6.1",
2114
+
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
2115
+
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
2116
+
"dev": true,
2117
+
"license": "BSD-3-Clause",
2118
+
"engines": {
2119
+
"node": ">=0.10.0"
2120
+
}
2121
+
},
2122
+
"node_modules/source-map-js": {
2123
+
"version": "1.2.1",
2124
+
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
2125
+
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2126
+
"dev": true,
2127
+
"license": "BSD-3-Clause",
2128
+
"engines": {
2129
+
"node": ">=0.10.0"
2130
+
}
2131
+
},
2132
+
"node_modules/stackback": {
2133
+
"version": "0.0.2",
2134
+
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
2135
+
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
2136
+
"dev": true,
2137
+
"license": "MIT"
2138
+
},
2139
+
"node_modules/stacktracey": {
2140
+
"version": "2.1.8",
2141
+
"resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz",
2142
+
"integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==",
2143
+
"dev": true,
2144
+
"license": "Unlicense",
2145
+
"dependencies": {
2146
+
"as-table": "^1.0.36",
2147
+
"get-source": "^2.0.12"
2148
+
}
2149
+
},
2150
+
"node_modules/std-env": {
2151
+
"version": "3.9.0",
2152
+
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
2153
+
"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
2154
+
"dev": true,
2155
+
"license": "MIT"
2156
+
},
2157
+
"node_modules/stoppable": {
2158
+
"version": "1.1.0",
2159
+
"resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz",
2160
+
"integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==",
2161
+
"dev": true,
2162
+
"license": "MIT",
2163
+
"engines": {
2164
+
"node": ">=4",
2165
+
"npm": ">=6"
2166
+
}
2167
+
},
2168
+
"node_modules/tinybench": {
2169
+
"version": "2.9.0",
2170
+
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
2171
+
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
2172
+
"dev": true,
2173
+
"license": "MIT"
2174
+
},
2175
+
"node_modules/tinyexec": {
2176
+
"version": "0.3.2",
2177
+
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
2178
+
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
2179
+
"dev": true,
2180
+
"license": "MIT"
2181
+
},
2182
+
"node_modules/tinyglobby": {
2183
+
"version": "0.2.13",
2184
+
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
2185
+
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
2186
+
"dev": true,
2187
+
"license": "MIT",
2188
+
"dependencies": {
2189
+
"fdir": "^6.4.4",
2190
+
"picomatch": "^4.0.2"
2191
+
},
2192
+
"engines": {
2193
+
"node": ">=12.0.0"
2194
+
},
2195
+
"funding": {
2196
+
"url": "https://github.com/sponsors/SuperchupuDev"
2197
+
}
2198
+
},
2199
+
"node_modules/tinypool": {
2200
+
"version": "1.0.2",
2201
+
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz",
2202
+
"integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==",
2203
+
"dev": true,
2204
+
"license": "MIT",
2205
+
"engines": {
2206
+
"node": "^18.0.0 || >=20.0.0"
2207
+
}
2208
+
},
2209
+
"node_modules/tinyrainbow": {
2210
+
"version": "2.0.0",
2211
+
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
2212
+
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
2213
+
"dev": true,
2214
+
"license": "MIT",
2215
+
"engines": {
2216
+
"node": ">=14.0.0"
2217
+
}
2218
+
},
2219
+
"node_modules/tinyspy": {
2220
+
"version": "3.0.2",
2221
+
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
2222
+
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
2223
+
"dev": true,
2224
+
"license": "MIT",
2225
+
"engines": {
2226
+
"node": ">=14.0.0"
2227
+
}
2228
+
},
2229
+
"node_modules/tslib": {
2230
+
"version": "2.8.1",
2231
+
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
2232
+
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
2233
+
"dev": true,
2234
+
"license": "0BSD",
2235
+
"optional": true
2236
+
},
2237
+
"node_modules/ufo": {
2238
+
"version": "1.6.1",
2239
+
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
2240
+
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
2241
+
"dev": true,
2242
+
"license": "MIT"
2243
+
},
2244
+
"node_modules/undici": {
2245
+
"version": "5.29.0",
2246
+
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
2247
+
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
2248
+
"dev": true,
2249
+
"license": "MIT",
2250
+
"dependencies": {
2251
+
"@fastify/busboy": "^2.0.0"
2252
+
},
2253
+
"engines": {
2254
+
"node": ">=14.0"
2255
+
}
2256
+
},
2257
+
"node_modules/unenv": {
2258
+
"version": "2.0.0-rc.15",
2259
+
"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.15.tgz",
2260
+
"integrity": "sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA==",
2261
+
"dev": true,
2262
+
"license": "MIT",
2263
+
"dependencies": {
2264
+
"defu": "^6.1.4",
2265
+
"exsolve": "^1.0.4",
2266
+
"ohash": "^2.0.11",
2267
+
"pathe": "^2.0.3",
2268
+
"ufo": "^1.5.4"
2269
+
}
2270
+
},
2271
+
"node_modules/vite": {
2272
+
"version": "6.3.4",
2273
+
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz",
2274
+
"integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==",
2275
+
"dev": true,
2276
+
"license": "MIT",
2277
+
"dependencies": {
2278
+
"esbuild": "^0.25.0",
2279
+
"fdir": "^6.4.4",
2280
+
"picomatch": "^4.0.2",
2281
+
"postcss": "^8.5.3",
2282
+
"rollup": "^4.34.9",
2283
+
"tinyglobby": "^0.2.13"
2284
+
},
2285
+
"bin": {
2286
+
"vite": "bin/vite.js"
2287
+
},
2288
+
"engines": {
2289
+
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
2290
+
},
2291
+
"funding": {
2292
+
"url": "https://github.com/vitejs/vite?sponsor=1"
2293
+
},
2294
+
"optionalDependencies": {
2295
+
"fsevents": "~2.3.3"
2296
+
},
2297
+
"peerDependencies": {
2298
+
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
2299
+
"jiti": ">=1.21.0",
2300
+
"less": "*",
2301
+
"lightningcss": "^1.21.0",
2302
+
"sass": "*",
2303
+
"sass-embedded": "*",
2304
+
"stylus": "*",
2305
+
"sugarss": "*",
2306
+
"terser": "^5.16.0",
2307
+
"tsx": "^4.8.1",
2308
+
"yaml": "^2.4.2"
2309
+
},
2310
+
"peerDependenciesMeta": {
2311
+
"@types/node": {
2312
+
"optional": true
2313
+
},
2314
+
"jiti": {
2315
+
"optional": true
2316
+
},
2317
+
"less": {
2318
+
"optional": true
2319
+
},
2320
+
"lightningcss": {
2321
+
"optional": true
2322
+
},
2323
+
"sass": {
2324
+
"optional": true
2325
+
},
2326
+
"sass-embedded": {
2327
+
"optional": true
2328
+
},
2329
+
"stylus": {
2330
+
"optional": true
2331
+
},
2332
+
"sugarss": {
2333
+
"optional": true
2334
+
},
2335
+
"terser": {
2336
+
"optional": true
2337
+
},
2338
+
"tsx": {
2339
+
"optional": true
2340
+
},
2341
+
"yaml": {
2342
+
"optional": true
2343
+
}
2344
+
}
2345
+
},
2346
+
"node_modules/vite-node": {
2347
+
"version": "3.0.9",
2348
+
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.9.tgz",
2349
+
"integrity": "sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg==",
2350
+
"dev": true,
2351
+
"license": "MIT",
2352
+
"dependencies": {
2353
+
"cac": "^6.7.14",
2354
+
"debug": "^4.4.0",
2355
+
"es-module-lexer": "^1.6.0",
2356
+
"pathe": "^2.0.3",
2357
+
"vite": "^5.0.0 || ^6.0.0"
2358
+
},
2359
+
"bin": {
2360
+
"vite-node": "vite-node.mjs"
2361
+
},
2362
+
"engines": {
2363
+
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
2364
+
},
2365
+
"funding": {
2366
+
"url": "https://opencollective.com/vitest"
2367
+
}
2368
+
},
2369
+
"node_modules/vitest": {
2370
+
"version": "3.0.9",
2371
+
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.9.tgz",
2372
+
"integrity": "sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==",
2373
+
"dev": true,
2374
+
"license": "MIT",
2375
+
"dependencies": {
2376
+
"@vitest/expect": "3.0.9",
2377
+
"@vitest/mocker": "3.0.9",
2378
+
"@vitest/pretty-format": "^3.0.9",
2379
+
"@vitest/runner": "3.0.9",
2380
+
"@vitest/snapshot": "3.0.9",
2381
+
"@vitest/spy": "3.0.9",
2382
+
"@vitest/utils": "3.0.9",
2383
+
"chai": "^5.2.0",
2384
+
"debug": "^4.4.0",
2385
+
"expect-type": "^1.1.0",
2386
+
"magic-string": "^0.30.17",
2387
+
"pathe": "^2.0.3",
2388
+
"std-env": "^3.8.0",
2389
+
"tinybench": "^2.9.0",
2390
+
"tinyexec": "^0.3.2",
2391
+
"tinypool": "^1.0.2",
2392
+
"tinyrainbow": "^2.0.0",
2393
+
"vite": "^5.0.0 || ^6.0.0",
2394
+
"vite-node": "3.0.9",
2395
+
"why-is-node-running": "^2.3.0"
2396
+
},
2397
+
"bin": {
2398
+
"vitest": "vitest.mjs"
2399
+
},
2400
+
"engines": {
2401
+
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
2402
+
},
2403
+
"funding": {
2404
+
"url": "https://opencollective.com/vitest"
2405
+
},
2406
+
"peerDependencies": {
2407
+
"@edge-runtime/vm": "*",
2408
+
"@types/debug": "^4.1.12",
2409
+
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
2410
+
"@vitest/browser": "3.0.9",
2411
+
"@vitest/ui": "3.0.9",
2412
+
"happy-dom": "*",
2413
+
"jsdom": "*"
2414
+
},
2415
+
"peerDependenciesMeta": {
2416
+
"@edge-runtime/vm": {
2417
+
"optional": true
2418
+
},
2419
+
"@types/debug": {
2420
+
"optional": true
2421
+
},
2422
+
"@types/node": {
2423
+
"optional": true
2424
+
},
2425
+
"@vitest/browser": {
2426
+
"optional": true
2427
+
},
2428
+
"@vitest/ui": {
2429
+
"optional": true
2430
+
},
2431
+
"happy-dom": {
2432
+
"optional": true
2433
+
},
2434
+
"jsdom": {
2435
+
"optional": true
2436
+
}
2437
+
}
2438
+
},
2439
+
"node_modules/why-is-node-running": {
2440
+
"version": "2.3.0",
2441
+
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
2442
+
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
2443
+
"dev": true,
2444
+
"license": "MIT",
2445
+
"dependencies": {
2446
+
"siginfo": "^2.0.0",
2447
+
"stackback": "0.0.2"
2448
+
},
2449
+
"bin": {
2450
+
"why-is-node-running": "cli.js"
2451
+
},
2452
+
"engines": {
2453
+
"node": ">=8"
2454
+
}
2455
+
},
2456
+
"node_modules/workerd": {
2457
+
"version": "1.20250428.0",
2458
+
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250428.0.tgz",
2459
+
"integrity": "sha512-JJNWkHkwPQKQdvtM9UORijgYdcdJsihA4SfYjwh02IUQsdMyZ9jizV1sX9yWi9B9ptlohTW8UNHJEATuphGgdg==",
2460
+
"dev": true,
2461
+
"hasInstallScript": true,
2462
+
"license": "Apache-2.0",
2463
+
"bin": {
2464
+
"workerd": "bin/workerd"
2465
+
},
2466
+
"engines": {
2467
+
"node": ">=16"
2468
+
},
2469
+
"optionalDependencies": {
2470
+
"@cloudflare/workerd-darwin-64": "1.20250428.0",
2471
+
"@cloudflare/workerd-darwin-arm64": "1.20250428.0",
2472
+
"@cloudflare/workerd-linux-64": "1.20250428.0",
2473
+
"@cloudflare/workerd-linux-arm64": "1.20250428.0",
2474
+
"@cloudflare/workerd-windows-64": "1.20250428.0"
2475
+
}
2476
+
},
2477
+
"node_modules/wrangler": {
2478
+
"version": "4.14.1",
2479
+
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.14.1.tgz",
2480
+
"integrity": "sha512-EU7IThP7i68TBftJJSveogvWZ5k/WRijcJh3UclDWiWWhDZTPbL6LOJEFhHKqFzHOaC4Y2Aewt48rfTz0e7oCw==",
2481
+
"dev": true,
2482
+
"license": "MIT OR Apache-2.0",
2483
+
"dependencies": {
2484
+
"@cloudflare/kv-asset-handler": "0.4.0",
2485
+
"@cloudflare/unenv-preset": "2.3.1",
2486
+
"blake3-wasm": "2.1.5",
2487
+
"esbuild": "0.25.2",
2488
+
"miniflare": "4.20250428.1",
2489
+
"path-to-regexp": "6.3.0",
2490
+
"unenv": "2.0.0-rc.15",
2491
+
"workerd": "1.20250428.0"
2492
+
},
2493
+
"bin": {
2494
+
"wrangler": "bin/wrangler.js",
2495
+
"wrangler2": "bin/wrangler.js"
2496
+
},
2497
+
"engines": {
2498
+
"node": ">=18.0.0"
2499
+
},
2500
+
"optionalDependencies": {
2501
+
"fsevents": "~2.3.2",
2502
+
"sharp": "^0.33.5"
2503
+
},
2504
+
"peerDependencies": {
2505
+
"@cloudflare/workers-types": "^4.20250428.0"
2506
+
},
2507
+
"peerDependenciesMeta": {
2508
+
"@cloudflare/workers-types": {
2509
+
"optional": true
2510
+
}
2511
+
}
2512
+
},
2513
+
"node_modules/wrangler/node_modules/@esbuild/aix-ppc64": {
2514
+
"version": "0.25.2",
2515
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
2516
+
"integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==",
2517
+
"cpu": [
2518
+
"ppc64"
2519
+
],
2520
+
"dev": true,
2521
+
"license": "MIT",
2522
+
"optional": true,
2523
+
"os": [
2524
+
"aix"
2525
+
],
2526
+
"engines": {
2527
+
"node": ">=18"
2528
+
}
2529
+
},
2530
+
"node_modules/wrangler/node_modules/@esbuild/android-arm": {
2531
+
"version": "0.25.2",
2532
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz",
2533
+
"integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==",
2534
+
"cpu": [
2535
+
"arm"
2536
+
],
2537
+
"dev": true,
2538
+
"license": "MIT",
2539
+
"optional": true,
2540
+
"os": [
2541
+
"android"
2542
+
],
2543
+
"engines": {
2544
+
"node": ">=18"
2545
+
}
2546
+
},
2547
+
"node_modules/wrangler/node_modules/@esbuild/android-arm64": {
2548
+
"version": "0.25.2",
2549
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz",
2550
+
"integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==",
2551
+
"cpu": [
2552
+
"arm64"
2553
+
],
2554
+
"dev": true,
2555
+
"license": "MIT",
2556
+
"optional": true,
2557
+
"os": [
2558
+
"android"
2559
+
],
2560
+
"engines": {
2561
+
"node": ">=18"
2562
+
}
2563
+
},
2564
+
"node_modules/wrangler/node_modules/@esbuild/android-x64": {
2565
+
"version": "0.25.2",
2566
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz",
2567
+
"integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==",
2568
+
"cpu": [
2569
+
"x64"
2570
+
],
2571
+
"dev": true,
2572
+
"license": "MIT",
2573
+
"optional": true,
2574
+
"os": [
2575
+
"android"
2576
+
],
2577
+
"engines": {
2578
+
"node": ">=18"
2579
+
}
2580
+
},
2581
+
"node_modules/wrangler/node_modules/@esbuild/darwin-arm64": {
2582
+
"version": "0.25.2",
2583
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz",
2584
+
"integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==",
2585
+
"cpu": [
2586
+
"arm64"
2587
+
],
2588
+
"dev": true,
2589
+
"license": "MIT",
2590
+
"optional": true,
2591
+
"os": [
2592
+
"darwin"
2593
+
],
2594
+
"engines": {
2595
+
"node": ">=18"
2596
+
}
2597
+
},
2598
+
"node_modules/wrangler/node_modules/@esbuild/darwin-x64": {
2599
+
"version": "0.25.2",
2600
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz",
2601
+
"integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==",
2602
+
"cpu": [
2603
+
"x64"
2604
+
],
2605
+
"dev": true,
2606
+
"license": "MIT",
2607
+
"optional": true,
2608
+
"os": [
2609
+
"darwin"
2610
+
],
2611
+
"engines": {
2612
+
"node": ">=18"
2613
+
}
2614
+
},
2615
+
"node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": {
2616
+
"version": "0.25.2",
2617
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz",
2618
+
"integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==",
2619
+
"cpu": [
2620
+
"arm64"
2621
+
],
2622
+
"dev": true,
2623
+
"license": "MIT",
2624
+
"optional": true,
2625
+
"os": [
2626
+
"freebsd"
2627
+
],
2628
+
"engines": {
2629
+
"node": ">=18"
2630
+
}
2631
+
},
2632
+
"node_modules/wrangler/node_modules/@esbuild/freebsd-x64": {
2633
+
"version": "0.25.2",
2634
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz",
2635
+
"integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==",
2636
+
"cpu": [
2637
+
"x64"
2638
+
],
2639
+
"dev": true,
2640
+
"license": "MIT",
2641
+
"optional": true,
2642
+
"os": [
2643
+
"freebsd"
2644
+
],
2645
+
"engines": {
2646
+
"node": ">=18"
2647
+
}
2648
+
},
2649
+
"node_modules/wrangler/node_modules/@esbuild/linux-arm": {
2650
+
"version": "0.25.2",
2651
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz",
2652
+
"integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==",
2653
+
"cpu": [
2654
+
"arm"
2655
+
],
2656
+
"dev": true,
2657
+
"license": "MIT",
2658
+
"optional": true,
2659
+
"os": [
2660
+
"linux"
2661
+
],
2662
+
"engines": {
2663
+
"node": ">=18"
2664
+
}
2665
+
},
2666
+
"node_modules/wrangler/node_modules/@esbuild/linux-arm64": {
2667
+
"version": "0.25.2",
2668
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz",
2669
+
"integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==",
2670
+
"cpu": [
2671
+
"arm64"
2672
+
],
2673
+
"dev": true,
2674
+
"license": "MIT",
2675
+
"optional": true,
2676
+
"os": [
2677
+
"linux"
2678
+
],
2679
+
"engines": {
2680
+
"node": ">=18"
2681
+
}
2682
+
},
2683
+
"node_modules/wrangler/node_modules/@esbuild/linux-ia32": {
2684
+
"version": "0.25.2",
2685
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz",
2686
+
"integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==",
2687
+
"cpu": [
2688
+
"ia32"
2689
+
],
2690
+
"dev": true,
2691
+
"license": "MIT",
2692
+
"optional": true,
2693
+
"os": [
2694
+
"linux"
2695
+
],
2696
+
"engines": {
2697
+
"node": ">=18"
2698
+
}
2699
+
},
2700
+
"node_modules/wrangler/node_modules/@esbuild/linux-loong64": {
2701
+
"version": "0.25.2",
2702
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz",
2703
+
"integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==",
2704
+
"cpu": [
2705
+
"loong64"
2706
+
],
2707
+
"dev": true,
2708
+
"license": "MIT",
2709
+
"optional": true,
2710
+
"os": [
2711
+
"linux"
2712
+
],
2713
+
"engines": {
2714
+
"node": ">=18"
2715
+
}
2716
+
},
2717
+
"node_modules/wrangler/node_modules/@esbuild/linux-mips64el": {
2718
+
"version": "0.25.2",
2719
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz",
2720
+
"integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==",
2721
+
"cpu": [
2722
+
"mips64el"
2723
+
],
2724
+
"dev": true,
2725
+
"license": "MIT",
2726
+
"optional": true,
2727
+
"os": [
2728
+
"linux"
2729
+
],
2730
+
"engines": {
2731
+
"node": ">=18"
2732
+
}
2733
+
},
2734
+
"node_modules/wrangler/node_modules/@esbuild/linux-ppc64": {
2735
+
"version": "0.25.2",
2736
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz",
2737
+
"integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==",
2738
+
"cpu": [
2739
+
"ppc64"
2740
+
],
2741
+
"dev": true,
2742
+
"license": "MIT",
2743
+
"optional": true,
2744
+
"os": [
2745
+
"linux"
2746
+
],
2747
+
"engines": {
2748
+
"node": ">=18"
2749
+
}
2750
+
},
2751
+
"node_modules/wrangler/node_modules/@esbuild/linux-riscv64": {
2752
+
"version": "0.25.2",
2753
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz",
2754
+
"integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==",
2755
+
"cpu": [
2756
+
"riscv64"
2757
+
],
2758
+
"dev": true,
2759
+
"license": "MIT",
2760
+
"optional": true,
2761
+
"os": [
2762
+
"linux"
2763
+
],
2764
+
"engines": {
2765
+
"node": ">=18"
2766
+
}
2767
+
},
2768
+
"node_modules/wrangler/node_modules/@esbuild/linux-s390x": {
2769
+
"version": "0.25.2",
2770
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz",
2771
+
"integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==",
2772
+
"cpu": [
2773
+
"s390x"
2774
+
],
2775
+
"dev": true,
2776
+
"license": "MIT",
2777
+
"optional": true,
2778
+
"os": [
2779
+
"linux"
2780
+
],
2781
+
"engines": {
2782
+
"node": ">=18"
2783
+
}
2784
+
},
2785
+
"node_modules/wrangler/node_modules/@esbuild/linux-x64": {
2786
+
"version": "0.25.2",
2787
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz",
2788
+
"integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==",
2789
+
"cpu": [
2790
+
"x64"
2791
+
],
2792
+
"dev": true,
2793
+
"license": "MIT",
2794
+
"optional": true,
2795
+
"os": [
2796
+
"linux"
2797
+
],
2798
+
"engines": {
2799
+
"node": ">=18"
2800
+
}
2801
+
},
2802
+
"node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": {
2803
+
"version": "0.25.2",
2804
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz",
2805
+
"integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==",
2806
+
"cpu": [
2807
+
"arm64"
2808
+
],
2809
+
"dev": true,
2810
+
"license": "MIT",
2811
+
"optional": true,
2812
+
"os": [
2813
+
"netbsd"
2814
+
],
2815
+
"engines": {
2816
+
"node": ">=18"
2817
+
}
2818
+
},
2819
+
"node_modules/wrangler/node_modules/@esbuild/netbsd-x64": {
2820
+
"version": "0.25.2",
2821
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz",
2822
+
"integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==",
2823
+
"cpu": [
2824
+
"x64"
2825
+
],
2826
+
"dev": true,
2827
+
"license": "MIT",
2828
+
"optional": true,
2829
+
"os": [
2830
+
"netbsd"
2831
+
],
2832
+
"engines": {
2833
+
"node": ">=18"
2834
+
}
2835
+
},
2836
+
"node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": {
2837
+
"version": "0.25.2",
2838
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz",
2839
+
"integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==",
2840
+
"cpu": [
2841
+
"arm64"
2842
+
],
2843
+
"dev": true,
2844
+
"license": "MIT",
2845
+
"optional": true,
2846
+
"os": [
2847
+
"openbsd"
2848
+
],
2849
+
"engines": {
2850
+
"node": ">=18"
2851
+
}
2852
+
},
2853
+
"node_modules/wrangler/node_modules/@esbuild/openbsd-x64": {
2854
+
"version": "0.25.2",
2855
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz",
2856
+
"integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==",
2857
+
"cpu": [
2858
+
"x64"
2859
+
],
2860
+
"dev": true,
2861
+
"license": "MIT",
2862
+
"optional": true,
2863
+
"os": [
2864
+
"openbsd"
2865
+
],
2866
+
"engines": {
2867
+
"node": ">=18"
2868
+
}
2869
+
},
2870
+
"node_modules/wrangler/node_modules/@esbuild/sunos-x64": {
2871
+
"version": "0.25.2",
2872
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz",
2873
+
"integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==",
2874
+
"cpu": [
2875
+
"x64"
2876
+
],
2877
+
"dev": true,
2878
+
"license": "MIT",
2879
+
"optional": true,
2880
+
"os": [
2881
+
"sunos"
2882
+
],
2883
+
"engines": {
2884
+
"node": ">=18"
2885
+
}
2886
+
},
2887
+
"node_modules/wrangler/node_modules/@esbuild/win32-arm64": {
2888
+
"version": "0.25.2",
2889
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz",
2890
+
"integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==",
2891
+
"cpu": [
2892
+
"arm64"
2893
+
],
2894
+
"dev": true,
2895
+
"license": "MIT",
2896
+
"optional": true,
2897
+
"os": [
2898
+
"win32"
2899
+
],
2900
+
"engines": {
2901
+
"node": ">=18"
2902
+
}
2903
+
},
2904
+
"node_modules/wrangler/node_modules/@esbuild/win32-ia32": {
2905
+
"version": "0.25.2",
2906
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz",
2907
+
"integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==",
2908
+
"cpu": [
2909
+
"ia32"
2910
+
],
2911
+
"dev": true,
2912
+
"license": "MIT",
2913
+
"optional": true,
2914
+
"os": [
2915
+
"win32"
2916
+
],
2917
+
"engines": {
2918
+
"node": ">=18"
2919
+
}
2920
+
},
2921
+
"node_modules/wrangler/node_modules/@esbuild/win32-x64": {
2922
+
"version": "0.25.2",
2923
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz",
2924
+
"integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==",
2925
+
"cpu": [
2926
+
"x64"
2927
+
],
2928
+
"dev": true,
2929
+
"license": "MIT",
2930
+
"optional": true,
2931
+
"os": [
2932
+
"win32"
2933
+
],
2934
+
"engines": {
2935
+
"node": ">=18"
2936
+
}
2937
+
},
2938
+
"node_modules/wrangler/node_modules/esbuild": {
2939
+
"version": "0.25.2",
2940
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz",
2941
+
"integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==",
2942
+
"dev": true,
2943
+
"hasInstallScript": true,
2944
+
"license": "MIT",
2945
+
"bin": {
2946
+
"esbuild": "bin/esbuild"
2947
+
},
2948
+
"engines": {
2949
+
"node": ">=18"
2950
+
},
2951
+
"optionalDependencies": {
2952
+
"@esbuild/aix-ppc64": "0.25.2",
2953
+
"@esbuild/android-arm": "0.25.2",
2954
+
"@esbuild/android-arm64": "0.25.2",
2955
+
"@esbuild/android-x64": "0.25.2",
2956
+
"@esbuild/darwin-arm64": "0.25.2",
2957
+
"@esbuild/darwin-x64": "0.25.2",
2958
+
"@esbuild/freebsd-arm64": "0.25.2",
2959
+
"@esbuild/freebsd-x64": "0.25.2",
2960
+
"@esbuild/linux-arm": "0.25.2",
2961
+
"@esbuild/linux-arm64": "0.25.2",
2962
+
"@esbuild/linux-ia32": "0.25.2",
2963
+
"@esbuild/linux-loong64": "0.25.2",
2964
+
"@esbuild/linux-mips64el": "0.25.2",
2965
+
"@esbuild/linux-ppc64": "0.25.2",
2966
+
"@esbuild/linux-riscv64": "0.25.2",
2967
+
"@esbuild/linux-s390x": "0.25.2",
2968
+
"@esbuild/linux-x64": "0.25.2",
2969
+
"@esbuild/netbsd-arm64": "0.25.2",
2970
+
"@esbuild/netbsd-x64": "0.25.2",
2971
+
"@esbuild/openbsd-arm64": "0.25.2",
2972
+
"@esbuild/openbsd-x64": "0.25.2",
2973
+
"@esbuild/sunos-x64": "0.25.2",
2974
+
"@esbuild/win32-arm64": "0.25.2",
2975
+
"@esbuild/win32-ia32": "0.25.2",
2976
+
"@esbuild/win32-x64": "0.25.2"
2977
+
}
2978
+
},
2979
+
"node_modules/ws": {
2980
+
"version": "8.18.0",
2981
+
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
2982
+
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
2983
+
"dev": true,
2984
+
"license": "MIT",
2985
+
"engines": {
2986
+
"node": ">=10.0.0"
2987
+
},
2988
+
"peerDependencies": {
2989
+
"bufferutil": "^4.0.1",
2990
+
"utf-8-validate": ">=5.0.2"
2991
+
},
2992
+
"peerDependenciesMeta": {
2993
+
"bufferutil": {
2994
+
"optional": true
2995
+
},
2996
+
"utf-8-validate": {
2997
+
"optional": true
2998
+
}
2999
+
}
3000
+
},
3001
+
"node_modules/youch": {
3002
+
"version": "3.3.4",
3003
+
"resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz",
3004
+
"integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==",
3005
+
"dev": true,
3006
+
"license": "MIT",
3007
+
"dependencies": {
3008
+
"cookie": "^0.7.1",
3009
+
"mustache": "^4.2.0",
3010
+
"stacktracey": "^2.1.8"
3011
+
}
3012
+
},
3013
+
"node_modules/zod": {
3014
+
"version": "3.24.3",
3015
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz",
3016
+
"integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==",
3017
+
"dev": true,
3018
+
"license": "MIT",
3019
+
"funding": {
3020
+
"url": "https://github.com/sponsors/colinhacks"
3021
+
}
3022
+
}
3023
+
}
3024
+
}
+16
camo/package.json
+16
camo/package.json
···
···
1
+
{
2
+
"name": "camo",
3
+
"version": "0.0.0",
4
+
"private": true,
5
+
"scripts": {
6
+
"deploy": "wrangler deploy",
7
+
"dev": "wrangler dev",
8
+
"start": "wrangler dev",
9
+
"test": "vitest"
10
+
},
11
+
"devDependencies": {
12
+
"@cloudflare/vitest-pool-workers": "^0.8.19",
13
+
"vitest": "~3.0.7",
14
+
"wrangler": "^4.14.1"
15
+
}
16
+
}
+17
camo/readme.md
+17
camo/readme.md
···
···
1
+
# camo
2
+
3
+
Camo is Tangled's "camouflage" service much like that of [GitHub's](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-anonymized-urls).
4
+
5
+
Camo uses a shared secret `CAMO_SHARED_SECRET` to verify HMAC signatures. URLs are of the form:
6
+
7
+
```
8
+
https://camo.tangled.sh/<signature>/<hex-encoded-origin-url>
9
+
```
10
+
11
+
It's pretty barebones for the moment and doesn't support a whole lot of what the
12
+
big G's does. Ours is a Cloudflare Worker, deployed using `wrangler` like so:
13
+
14
+
```
15
+
npx wrangler deploy
16
+
npx wrangler secrets put CAMO_SHARED_SECRET
17
+
```
+101
camo/src/index.js
+101
camo/src/index.js
···
···
1
+
export default {
2
+
async fetch(request, env) {
3
+
const url = new URL(request.url);
4
+
5
+
if (url.pathname === "/" || url.pathname === "") {
6
+
return new Response(
7
+
"This is Tangled's Camo service. It proxies images served from knots via Cloudflare.",
8
+
);
9
+
}
10
+
11
+
const cache = caches.default;
12
+
13
+
const pathParts = url.pathname.slice(1).split("/");
14
+
if (pathParts.length < 2) {
15
+
return new Response("Bad URL", { status: 400 });
16
+
}
17
+
18
+
const [signatureHex, ...hexUrlParts] = pathParts;
19
+
const hexUrl = hexUrlParts.join("");
20
+
const urlBytes = Uint8Array.from(
21
+
hexUrl.match(/.{2}/g).map((b) => parseInt(b, 16)),
22
+
);
23
+
const targetUrl = new TextDecoder().decode(urlBytes);
24
+
25
+
// check if we have an entry in the cache with the target url
26
+
let cacheKey = new Request(targetUrl);
27
+
let response = await cache.match(cacheKey);
28
+
if (response) {
29
+
return response;
30
+
}
31
+
32
+
// else compute the signature
33
+
const key = await crypto.subtle.importKey(
34
+
"raw",
35
+
new TextEncoder().encode(env.CAMO_SHARED_SECRET),
36
+
{ name: "HMAC", hash: "SHA-256" },
37
+
false,
38
+
["sign", "verify"],
39
+
);
40
+
41
+
const computedSigBuffer = await crypto.subtle.sign("HMAC", key, urlBytes);
42
+
const computedSig = Array.from(new Uint8Array(computedSigBuffer))
43
+
.map((b) => b.toString(16).padStart(2, "0"))
44
+
.join("");
45
+
46
+
console.log({
47
+
level: "debug",
48
+
message: "camo target: " + targetUrl,
49
+
computedSignature: computedSig,
50
+
providedSignature: signatureHex,
51
+
targetUrl: targetUrl,
52
+
});
53
+
54
+
const sigBytes = Uint8Array.from(
55
+
signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)),
56
+
);
57
+
const valid = await crypto.subtle.verify("HMAC", key, sigBytes, urlBytes);
58
+
59
+
if (!valid) {
60
+
return new Response("Invalid signature", { status: 403 });
61
+
}
62
+
63
+
let parsedUrl;
64
+
try {
65
+
parsedUrl = new URL(targetUrl);
66
+
if (!["https:", "http:"].includes(parsedUrl.protocol)) {
67
+
return new Response("Only HTTP(S) allowed", { status: 400 });
68
+
}
69
+
} catch {
70
+
return new Response("Malformed URL", { status: 400 });
71
+
}
72
+
73
+
// fetch from the parsed URL
74
+
const res = await fetch(parsedUrl.toString(), {
75
+
headers: { "User-Agent": "Tangled Camo v0.1.0" },
76
+
});
77
+
78
+
const allowedMimeTypes = require("./mimetypes.json");
79
+
80
+
const contentType =
81
+
res.headers.get("Content-Type") || "application/octet-stream";
82
+
83
+
if (!allowedMimeTypes.includes(contentType.split(";")[0].trim())) {
84
+
return new Response("Unsupported media type", { status: 415 });
85
+
}
86
+
87
+
const headers = new Headers();
88
+
headers.set("Content-Type", contentType);
89
+
headers.set("Cache-Control", "public, max-age=86400, immutable");
90
+
91
+
// serve and cache it with cf
92
+
response = new Response(await res.arrayBuffer(), {
93
+
status: res.status,
94
+
headers,
95
+
});
96
+
97
+
await cache.put(cacheKey, response.clone());
98
+
99
+
return response;
100
+
},
101
+
};
+45
camo/src/mimetypes.json
+45
camo/src/mimetypes.json
···
···
1
+
[
2
+
"image/bmp",
3
+
"image/cgm",
4
+
"image/g3fax",
5
+
"image/gif",
6
+
"image/ief",
7
+
"image/jp2",
8
+
"image/jpeg",
9
+
"image/jpg",
10
+
"image/pict",
11
+
"image/png",
12
+
"image/prs.btif",
13
+
"image/svg+xml",
14
+
"image/tiff",
15
+
"image/vnd.adobe.photoshop",
16
+
"image/vnd.djvu",
17
+
"image/vnd.dwg",
18
+
"image/vnd.dxf",
19
+
"image/vnd.fastbidsheet",
20
+
"image/vnd.fpx",
21
+
"image/vnd.fst",
22
+
"image/vnd.fujixerox.edmics-mmr",
23
+
"image/vnd.fujixerox.edmics-rlc",
24
+
"image/vnd.microsoft.icon",
25
+
"image/vnd.ms-modi",
26
+
"image/vnd.net-fpx",
27
+
"image/vnd.wap.wbmp",
28
+
"image/vnd.xiff",
29
+
"image/webp",
30
+
"image/x-cmu-raster",
31
+
"image/x-cmx",
32
+
"image/x-icon",
33
+
"image/x-macpaint",
34
+
"image/x-pcx",
35
+
"image/x-pict",
36
+
"image/x-portable-anymap",
37
+
"image/x-portable-bitmap",
38
+
"image/x-portable-graymap",
39
+
"image/x-portable-pixmap",
40
+
"image/x-quicktime",
41
+
"image/x-rgb",
42
+
"image/x-xbitmap",
43
+
"image/x-xpixmap",
44
+
"image/x-xwindowdump"
45
+
]
+20
camo/wrangler.jsonc
+20
camo/wrangler.jsonc
···
···
1
+
/**
2
+
* For more details on how to configure Wrangler, refer to:
3
+
* https://developers.cloudflare.com/workers/wrangler/configuration/
4
+
*/
5
+
{
6
+
"$schema": "node_modules/wrangler/config-schema.json",
7
+
"name": "camo",
8
+
"main": "src/index.js",
9
+
"compatibility_date": "2025-04-30",
10
+
"observability": {
11
+
"enabled": true,
12
+
},
13
+
14
+
"routes": [
15
+
{
16
+
"pattern": "camo.tangled.sh",
17
+
"custom_domain": true,
18
+
},
19
+
],
20
+
}
+2
-2
cmd/appview/main.go
+2
-2
cmd/appview/main.go
+38
cmd/combinediff/main.go
+38
cmd/combinediff/main.go
···
···
1
+
package main
2
+
3
+
import (
4
+
"fmt"
5
+
"os"
6
+
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
+
"tangled.sh/tangled.sh/core/patchutil"
9
+
)
10
+
11
+
func main() {
12
+
if len(os.Args) != 3 {
13
+
fmt.Println("Usage: combinediff <patch1> <patch2>")
14
+
os.Exit(1)
15
+
}
16
+
17
+
patch1, err := os.Open(os.Args[1])
18
+
if err != nil {
19
+
fmt.Println(err)
20
+
}
21
+
patch2, err := os.Open(os.Args[2])
22
+
if err != nil {
23
+
fmt.Println(err)
24
+
}
25
+
26
+
files1, _, err := gitdiff.Parse(patch1)
27
+
if err != nil {
28
+
fmt.Println(err)
29
+
}
30
+
31
+
files2, _, err := gitdiff.Parse(patch2)
32
+
if err != nil {
33
+
fmt.Println(err)
34
+
}
35
+
36
+
combined := patchutil.CombineDiff(files1, files2)
37
+
fmt.Println(combined)
38
+
}
+15
-12
cmd/gen.go
+15
-12
cmd/gen.go
···
2
3
import (
4
cbg "github.com/whyrusleeping/cbor-gen"
5
-
shtangled "tangled.sh/tangled.sh/core/api/tangled"
6
)
7
8
func main() {
···
14
if err := genCfg.WriteMapEncodersToFile(
15
"api/tangled/cbor_gen.go",
16
"tangled",
17
-
shtangled.FeedStar{},
18
-
shtangled.GraphFollow{},
19
-
shtangled.KnotMember{},
20
-
shtangled.PublicKey{},
21
-
shtangled.RepoIssueComment{},
22
-
shtangled.RepoIssueState{},
23
-
shtangled.RepoIssue{},
24
-
shtangled.Repo{},
25
-
shtangled.RepoPull{},
26
-
shtangled.RepoPullStatus{},
27
-
shtangled.RepoPullComment{},
28
); err != nil {
29
panic(err)
30
}
···
2
3
import (
4
cbg "github.com/whyrusleeping/cbor-gen"
5
+
"tangled.sh/tangled.sh/core/api/tangled"
6
)
7
8
func main() {
···
14
if err := genCfg.WriteMapEncodersToFile(
15
"api/tangled/cbor_gen.go",
16
"tangled",
17
+
tangled.FeedStar{},
18
+
tangled.GraphFollow{},
19
+
tangled.KnotMember{},
20
+
tangled.PublicKey{},
21
+
tangled.RepoIssueComment{},
22
+
tangled.RepoIssueState{},
23
+
tangled.RepoIssue{},
24
+
tangled.Repo{},
25
+
tangled.RepoPull{},
26
+
tangled.RepoPull_Source{},
27
+
tangled.RepoPullStatus{},
28
+
tangled.RepoPullComment{},
29
+
tangled.RepoArtifact{},
30
+
tangled.ActorProfile{},
31
); err != nil {
32
panic(err)
33
}
+39
cmd/genjwks/main.go
+39
cmd/genjwks/main.go
···
···
1
+
// adapted from https://github.com/haileyok/atproto-oauth-golang
2
+
3
+
package main
4
+
5
+
import (
6
+
"crypto/ecdsa"
7
+
"crypto/elliptic"
8
+
"crypto/rand"
9
+
"encoding/json"
10
+
"fmt"
11
+
"time"
12
+
13
+
"github.com/lestrrat-go/jwx/v2/jwk"
14
+
)
15
+
16
+
func main() {
17
+
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
18
+
if err != nil {
19
+
panic(err)
20
+
}
21
+
22
+
key, err := jwk.FromRaw(privKey)
23
+
if err != nil {
24
+
panic(err)
25
+
}
26
+
27
+
kid := fmt.Sprintf("%d", time.Now().Unix())
28
+
29
+
if err := key.Set(jwk.KeyIDKey, kid); err != nil {
30
+
panic(err)
31
+
}
32
+
33
+
b, err := json.Marshal(key)
34
+
if err != nil {
35
+
panic(err)
36
+
}
37
+
38
+
fmt.Println(string(b))
39
+
}
+38
cmd/interdiff/main.go
+38
cmd/interdiff/main.go
···
···
1
+
package main
2
+
3
+
import (
4
+
"fmt"
5
+
"os"
6
+
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
+
"tangled.sh/tangled.sh/core/patchutil"
9
+
)
10
+
11
+
func main() {
12
+
if len(os.Args) != 3 {
13
+
fmt.Println("Usage: interdiff <patch1> <patch2>")
14
+
os.Exit(1)
15
+
}
16
+
17
+
patch1, err := os.Open(os.Args[1])
18
+
if err != nil {
19
+
fmt.Println(err)
20
+
}
21
+
patch2, err := os.Open(os.Args[2])
22
+
if err != nil {
23
+
fmt.Println(err)
24
+
}
25
+
26
+
files1, _, err := gitdiff.Parse(patch1)
27
+
if err != nil {
28
+
fmt.Println(err)
29
+
}
30
+
31
+
files2, _, err := gitdiff.Parse(patch2)
32
+
if err != nil {
33
+
fmt.Println(err)
34
+
}
35
+
36
+
interDiffResult := patchutil.Interdiff(files1, files2)
37
+
fmt.Println(interDiffResult)
38
+
}
-150
cmd/jstest/main.go
-150
cmd/jstest/main.go
···
1
-
package main
2
-
3
-
import (
4
-
"context"
5
-
"flag"
6
-
"log/slog"
7
-
"os"
8
-
"os/signal"
9
-
"strings"
10
-
"syscall"
11
-
"time"
12
-
13
-
"github.com/bluesky-social/jetstream/pkg/client"
14
-
"github.com/bluesky-social/jetstream/pkg/models"
15
-
"tangled.sh/tangled.sh/core/jetstream"
16
-
)
17
-
18
-
// Simple in-memory implementation of DB interface
19
-
type MemoryDB struct {
20
-
lastTimeUs int64
21
-
}
22
-
23
-
func (m *MemoryDB) GetLastTimeUs() (int64, error) {
24
-
if m.lastTimeUs == 0 {
25
-
return time.Now().UnixMicro(), nil
26
-
}
27
-
return m.lastTimeUs, nil
28
-
}
29
-
30
-
func (m *MemoryDB) SaveLastTimeUs(ts int64) error {
31
-
m.lastTimeUs = ts
32
-
return nil
33
-
}
34
-
35
-
func (m *MemoryDB) UpdateLastTimeUs(ts int64) error {
36
-
m.lastTimeUs = ts
37
-
return nil
38
-
}
39
-
40
-
func main() {
41
-
// Setup logger
42
-
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
43
-
Level: slog.LevelInfo,
44
-
}))
45
-
46
-
// Create in-memory DB
47
-
db := &MemoryDB{}
48
-
49
-
// Get query URL from flag
50
-
var queryURL string
51
-
flag.StringVar(&queryURL, "query-url", "", "Jetstream query URL containing DIDs")
52
-
flag.Parse()
53
-
54
-
if queryURL == "" {
55
-
logger.Error("No query URL provided, use --query-url flag")
56
-
os.Exit(1)
57
-
}
58
-
59
-
// Extract wantedDids parameters
60
-
didParams := strings.Split(queryURL, "&wantedDids=")
61
-
dids := make([]string, 0, len(didParams)-1)
62
-
for i, param := range didParams {
63
-
if i == 0 {
64
-
// Skip the first part (the base URL with cursor)
65
-
continue
66
-
}
67
-
dids = append(dids, param)
68
-
}
69
-
70
-
// Extract collections
71
-
collections := []string{"sh.tangled.publicKey", "sh.tangled.knot.member"}
72
-
73
-
// Create client configuration
74
-
cfg := client.DefaultClientConfig()
75
-
cfg.WebsocketURL = "wss://jetstream2.us-west.bsky.network/subscribe"
76
-
cfg.WantedCollections = collections
77
-
78
-
// Create jetstream client
79
-
jsClient, err := jetstream.NewJetstreamClient(
80
-
cfg.WebsocketURL,
81
-
"tangled-jetstream",
82
-
collections,
83
-
cfg,
84
-
logger,
85
-
db,
86
-
false,
87
-
)
88
-
if err != nil {
89
-
logger.Error("Failed to create jetstream client", "error", err)
90
-
os.Exit(1)
91
-
}
92
-
93
-
// Update DIDs
94
-
jsClient.UpdateDids(dids)
95
-
96
-
// Create a context that will be canceled on SIGINT or SIGTERM
97
-
ctx, cancel := context.WithCancel(context.Background())
98
-
defer cancel()
99
-
100
-
// Setup signal handling with a buffered channel
101
-
sigCh := make(chan os.Signal, 1)
102
-
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
103
-
104
-
// Process function for events
105
-
processFunc := func(ctx context.Context, event *models.Event) error {
106
-
// Log the event details
107
-
logger.Info("Received event",
108
-
"collection", event.Commit.Collection,
109
-
"did", event.Did,
110
-
"rkey", event.Commit.RKey,
111
-
"action", event.Kind,
112
-
"time_us", event.TimeUS,
113
-
)
114
-
115
-
// Save the last time_us
116
-
if err := db.UpdateLastTimeUs(event.TimeUS); err != nil {
117
-
logger.Error("Failed to update last time_us", "error", err)
118
-
}
119
-
120
-
return nil
121
-
}
122
-
123
-
// Start jetstream
124
-
if err := jsClient.StartJetstream(ctx, processFunc); err != nil {
125
-
logger.Error("Failed to start jetstream", "error", err)
126
-
os.Exit(1)
127
-
}
128
-
129
-
// Wait for signal instead of context.Done()
130
-
sig := <-sigCh
131
-
logger.Info("Received signal, shutting down", "signal", sig)
132
-
cancel() // Cancel context after receiving signal
133
-
134
-
// Shutdown gracefully with a timeout
135
-
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
136
-
defer shutdownCancel()
137
-
138
-
done := make(chan struct{})
139
-
go func() {
140
-
jsClient.Shutdown()
141
-
close(done)
142
-
}()
143
-
144
-
select {
145
-
case <-done:
146
-
logger.Info("Jetstream client shut down gracefully")
147
-
case <-shutdownCtx.Done():
148
-
logger.Warn("Shutdown timed out, forcing exit")
149
-
}
150
-
}
···
+1
-1
cmd/knotserver/main.go
+1
-1
cmd/knotserver/main.go
+50
docker/Dockerfile
+50
docker/Dockerfile
···
···
1
+
FROM docker.io/golang:1.24-alpine3.21 AS build
2
+
3
+
ENV CGO_ENABLED=1
4
+
5
+
RUN apk add --no-cache gcc musl-dev
6
+
7
+
WORKDIR /usr/src/app
8
+
9
+
COPY go.mod go.sum ./
10
+
RUN go mod download
11
+
12
+
COPY . .
13
+
RUN go build -v \
14
+
-o /usr/local/bin/knotserver \
15
+
-ldflags='-s -w -extldflags "-static"' \
16
+
./cmd/knotserver && \
17
+
go build -v \
18
+
-o /usr/local/bin/keyfetch \
19
+
./cmd/keyfetch && \
20
+
go build -v \
21
+
-o /usr/local/bin/repoguard \
22
+
./cmd/repoguard
23
+
24
+
FROM docker.io/alpine:3.21
25
+
26
+
LABEL org.opencontainers.image.title=Tangled
27
+
LABEL org.opencontainers.image.description="Tangled is a decentralized and open code collaboration platform, built on atproto."
28
+
LABEL org.opencontainers.image.vendor=Tangled.sh
29
+
LABEL org.opencontainers.image.licenses=MIT
30
+
LABEL org.opencontainers.image.url=https://tangled.sh
31
+
LABEL org.opencontainers.image.source=https://tangled.sh/@tangled.sh/core
32
+
33
+
RUN apk add --no-cache shadow s6-overlay execline openssh git && \
34
+
adduser --disabled-password git && \
35
+
# We need to set password anyway since otherwise ssh won't work
36
+
head -c 32 /dev/random | base64 | tr -dc 'a-zA-Z0-9' | passwd git --stdin && \
37
+
mkdir /app && mkdir /home/git/repositories
38
+
39
+
COPY --from=build /usr/local/bin/knotserver /usr/local/bin
40
+
COPY --from=build /usr/local/bin/keyfetch /usr/local/libexec/tangled-keyfetch
41
+
COPY --from=build /usr/local/bin/repoguard /home/git/repoguard
42
+
COPY docker/rootfs/ .
43
+
44
+
RUN chown root:root /usr/local/libexec/tangled-keyfetch && \
45
+
chmod 755 /usr/local/libexec/tangled-keyfetch
46
+
47
+
EXPOSE 22
48
+
EXPOSE 5555
49
+
50
+
ENTRYPOINT ["/bin/sh", "-c", "chown git:git /home/git/repoguard && chown git:git /app && chown git:git /home/git/repositories && /init"]
+33
docker/docker-compose.yml
+33
docker/docker-compose.yml
···
···
1
+
services:
2
+
knot:
3
+
build:
4
+
context: ..
5
+
dockerfile: docker/Dockerfile
6
+
environment:
7
+
KNOT_SERVER_HOSTNAME: ${KNOT_SERVER_HOSTNAME}
8
+
KNOT_SERVER_SECRET: ${KNOT_SERVER_SECRET}
9
+
KNOT_SERVER_DB_PATH: "/app/knotserver.db"
10
+
KNOT_REPO_SCAN_PATH: "/home/git/repositories"
11
+
volumes:
12
+
- "./keys:/etc/ssh/keys"
13
+
- "./repositories:/home/git/repositories"
14
+
- "./server:/app"
15
+
ports:
16
+
- "2222:22"
17
+
frontend:
18
+
image: caddy:2-alpine
19
+
command: >
20
+
caddy
21
+
reverse-proxy
22
+
--from ${KNOT_SERVER_HOSTNAME}
23
+
--to knot:5555
24
+
depends_on:
25
+
- knot
26
+
ports:
27
+
- "443:443"
28
+
- "443:443/udp"
29
+
volumes:
30
+
- caddy_data:/data
31
+
restart: always
32
+
volumes:
33
+
caddy_data:
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/type
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/type
···
···
1
+
oneshot
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/up
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/up
···
···
1
+
/etc/s6-overlay/scripts/create-sshd-host-keys
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/dependencies.d/base
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/dependencies.d/base
This is a binary file and will not be displayed.
+3
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/run
+3
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/run
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/type
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/type
···
···
1
+
longrun
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/base
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/base
This is a binary file and will not be displayed.
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/create-sshd-host-keys
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/create-sshd-host-keys
This is a binary file and will not be displayed.
+3
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/run
+3
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/run
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/type
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/type
···
···
1
+
longrun
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/knotserver
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/knotserver
This is a binary file and will not be displayed.
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/sshd
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/sshd
This is a binary file and will not be displayed.
+21
docker/rootfs/etc/s6-overlay/scripts/create-sshd-host-keys
+21
docker/rootfs/etc/s6-overlay/scripts/create-sshd-host-keys
···
···
1
+
#!/usr/bin/execlineb -P
2
+
3
+
foreground {
4
+
if -n { test -d /etc/ssh/keys }
5
+
mkdir /etc/ssh/keys
6
+
}
7
+
8
+
foreground {
9
+
if -n { test -f /etc/ssh/keys/ssh_host_rsa_key }
10
+
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_rsa_key -q -N ""
11
+
}
12
+
13
+
foreground {
14
+
if -n { test -f /etc/ssh/keys/ssh_host_ecdsa_key }
15
+
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_ecdsa_key -q -N ""
16
+
}
17
+
18
+
foreground {
19
+
if -n { test -f /etc/ssh/keys/ssh_host_ed25519_key }
20
+
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_ed25519_key -q -N ""
21
+
}
+9
docker/rootfs/etc/ssh/sshd_config.d/tangled_sshd.conf
+9
docker/rootfs/etc/ssh/sshd_config.d/tangled_sshd.conf
···
···
1
+
HostKey /etc/ssh/keys/ssh_host_rsa_key
2
+
HostKey /etc/ssh/keys/ssh_host_ecdsa_key
3
+
HostKey /etc/ssh/keys/ssh_host_ed25519_key
4
+
5
+
PasswordAuthentication no
6
+
7
+
Match User git
8
+
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -git-dir /home/git/repositories
9
+
AuthorizedKeysCommandUser nobody
+76
docs/contributing.md
+76
docs/contributing.md
···
···
1
+
# tangled contributing guide
2
+
3
+
## commit guidelines
4
+
5
+
We follow a commit style similar to the Go project. Please keep commits:
6
+
7
+
* **atomic**: each commit should represent one logical change
8
+
* **descriptive**: the commit message should clearly describe what the
9
+
change does and why it's needed
10
+
11
+
### message format
12
+
13
+
```
14
+
<service/top-level directory>: <affected package/directory>: <short summary of change>
15
+
16
+
17
+
Optional longer description can go here, if necessary. Explain what the
18
+
change does and why, especially if not obvious. Reference relevant
19
+
issues or PRs when applicable. These can be links for now since we don't
20
+
auto-link issues/PRs yet.
21
+
```
22
+
23
+
Here are some examples:
24
+
25
+
```
26
+
appview: state: fix token expiry check in middleware
27
+
28
+
The previous check did not account for clock drift, leading to premature
29
+
token invalidation.
30
+
```
31
+
32
+
```
33
+
knotserver: git/service: improve error checking in upload-pack
34
+
```
35
+
36
+
### general notes
37
+
38
+
- PRs get merged "as-is" (fast-forward) -- like applying a patch-series
39
+
using `git am`. At present, there is no squashing -- so please author
40
+
your commits as they would appear on `master`, following the above
41
+
guidelines.
42
+
- Use the imperative mood in the summary line (e.g., "fix bug" not
43
+
"fixed bug" or "fixes bug").
44
+
- Try to keep the summary line under 72 characters, but we aren't too
45
+
fussed about this.
46
+
- Don't include unrelated changes in the same commit.
47
+
- Avoid noisy commit messages like "wip" or "final fix"โrewrite history
48
+
before submitting if necessary.
49
+
50
+
## proposals for bigger changes
51
+
52
+
Small fixes like typos, minor bugs, or trivial refactors can be
53
+
submitted directly as PRs.
54
+
55
+
For larger changesโespecially those introducing new features,
56
+
significant refactoring, or altering system behaviorโplease open a
57
+
proposal first. This helps us evaluate the scope, design, and potential
58
+
impact before implementation.
59
+
60
+
### proposal format
61
+
62
+
Create a new issue titled:
63
+
64
+
```
65
+
proposal: <affected scope>: <summary of change>
66
+
```
67
+
68
+
In the description, explain:
69
+
70
+
- What the change is
71
+
- Why it's needed
72
+
- How you plan to implement it (roughly)
73
+
- Any open questions or tradeoffs
74
+
75
+
We'll use the issue thread to discuss and refine the idea before moving
76
+
forward.
+72
docs/hacking.md
+72
docs/hacking.md
···
···
1
+
# hacking on tangled
2
+
3
+
We highly recommend [installing
4
+
nix](https://nixos.org/download/) (the package manager)
5
+
before working on the codebase. The nix flake provides a lot
6
+
of helpers to get started and most importantly, builds and
7
+
dev shells are entirely deterministic.
8
+
9
+
To set up your dev environment:
10
+
11
+
```bash
12
+
nix develop
13
+
```
14
+
15
+
Non-nix users can look at the `devShell` attribute in the
16
+
`flake.nix` file to determine necessary dependencies.
17
+
18
+
## running the appview
19
+
20
+
The nix flake also exposes a few `app` attributes (run `nix
21
+
flake show` to see a full list of what the flake provides),
22
+
one of the apps runs the appview with the `air`
23
+
live-reloader:
24
+
25
+
```bash
26
+
TANGLED_DEV=true nix run .#watch-appview
27
+
28
+
# TANGLED_DB_PATH might be of interest to point to
29
+
# different sqlite DBs
30
+
31
+
# in a separate shell, you can live-reload tailwind
32
+
nix run .#watch-tailwind
33
+
```
34
+
35
+
## running a knotserver
36
+
37
+
An end-to-end knotserver setup requires setting up a machine
38
+
with `sshd`, `repoguard`, `keyfetch`, a git user, which is
39
+
quite cumbersome and so the nix flake provides a
40
+
`nixosConfiguration` to do so.
41
+
42
+
To begin, head to `http://localhost:3000` in the browser and
43
+
generate a knotserver secret. Replace the existing secret in
44
+
`flake.nix` with the newly generated secret.
45
+
46
+
You can now start a lightweight NixOS VM using
47
+
`nixos-shell` like so:
48
+
49
+
```bash
50
+
QEMU_NET_OPTS="hostfwd=tcp::6000-:6000,hostfwd=tcp::2222-:22" nixos-shell --flake .#knotVM
51
+
52
+
# hit Ctrl-a + c + q to exit the VM
53
+
```
54
+
55
+
This starts a knotserver on port 6000 with `ssh` exposed on
56
+
port 2222. You can push repositories to this VM with this
57
+
ssh config block on your main machine:
58
+
59
+
```bash
60
+
Host nixos-shell
61
+
Hostname localhost
62
+
Port 2222
63
+
User git
64
+
IdentityFile ~/.ssh/my_tangled_key
65
+
```
66
+
67
+
Set up a remote called `local-dev` on a git repo:
68
+
69
+
```bash
70
+
git remote add local-dev git@nixos-shell:user/repo
71
+
git push local-dev main
72
+
```
+190
docs/knot-hosting.md
+190
docs/knot-hosting.md
···
···
1
+
# knot self-hosting guide
2
+
3
+
So you want to run your own knot server? Great! Here are a few prerequisites:
4
+
5
+
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind.
6
+
2. A (sub)domain name. People generally use `knot.example.com`.
7
+
3. A valid SSL certificate for your domain.
8
+
9
+
There's a couple of ways to get started:
10
+
* NixOS: refer to [flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix)
11
+
* Docker: Documented below.
12
+
* Manual: Documented below.
13
+
14
+
## docker setup
15
+
16
+
Clone this repository:
17
+
18
+
```
19
+
git clone https://tangled.sh/@tangled.sh/core
20
+
```
21
+
22
+
Modify the `docker/docker-compose.yml`, specifically the
23
+
`KNOT_SERVER_SECRET` and `KNOT_SERVER_HOSTNAME` env vars. Then run:
24
+
25
+
```
26
+
docker compose -f docker/docker-compose.yml up
27
+
```
28
+
29
+
## manual setup
30
+
31
+
First, clone this repository:
32
+
33
+
```
34
+
git clone https://tangled.sh/@tangled.sh/core
35
+
```
36
+
37
+
Then, build our binaries (you need to have Go installed):
38
+
* `knotserver`: the main server program
39
+
* `keyfetch`: utility to fetch ssh pubkeys
40
+
* `repoguard`: enforces repository access control
41
+
42
+
```
43
+
cd core
44
+
export CGO_ENABLED=1
45
+
go build -o knot ./cmd/knotserver
46
+
go build -o keyfetch ./cmd/keyfetch
47
+
go build -o repoguard ./cmd/repoguard
48
+
```
49
+
50
+
Next, move the `keyfetch` binary to a location owned by `root` --
51
+
`/usr/local/libexec/tangled-keyfetch` is a good choice:
52
+
53
+
```
54
+
sudo mv keyfetch /usr/local/libexec/tangled-keyfetch
55
+
sudo chown root:root /usr/local/libexec/tangled-keyfetch
56
+
sudo chmod 755 /usr/local/libexec/tangled-keyfetch
57
+
```
58
+
59
+
This is necessary because SSH `AuthorizedKeysCommand` requires [really specific
60
+
permissions](https://stackoverflow.com/a/27638306). Let's set that up:
61
+
62
+
```
63
+
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
64
+
Match User git
65
+
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch
66
+
AuthorizedKeysCommandUser nobody
67
+
EOF
68
+
```
69
+
70
+
Next, create the `git` user:
71
+
72
+
```
73
+
sudo adduser git
74
+
```
75
+
76
+
Copy the `repoguard` binary to the `git` user's home directory:
77
+
78
+
```
79
+
sudo cp repoguard /home/git
80
+
sudo chown git:git /home/git/repoguard
81
+
```
82
+
83
+
Now, let's set up the server. Copy the `knot` binary to
84
+
`/usr/local/bin/knotserver`. Then, create `/home/git/.knot.env` with the
85
+
following, updating the values as necessary. The `KNOT_SERVER_SECRET` can be
86
+
obtaind from the [/knots](/knots) page on Tangled.
87
+
88
+
```
89
+
KNOT_REPO_SCAN_PATH=/home/git
90
+
KNOT_SERVER_HOSTNAME=knot.example.com
91
+
APPVIEW_ENDPOINT=https://tangled.sh
92
+
KNOT_SERVER_SECRET=secret
93
+
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
94
+
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
95
+
```
96
+
97
+
If you run a Linux distribution that uses systemd, you can use the provided
98
+
service file to run the server. Copy
99
+
[`knotserver.service`](https://tangled.sh/did:plc:wshs7t2adsemcrrd4snkeqli/core/blob/master/systemd/knotserver.service)
100
+
to `/etc/systemd/system/`. Then, run:
101
+
102
+
```
103
+
systemctl enable knotserver
104
+
systemctl start knotserver
105
+
```
106
+
107
+
You should now have a running knot server! You can finalize your registration by hitting the
108
+
`initialize` button on the [/knots](/knots) page.
109
+
110
+
### custom paths
111
+
112
+
(This section applies to manual setup only. Docker users should edit the mounts
113
+
in `docker-compose.yml` instead.)
114
+
115
+
Right now, the database and repositories of your knot lives in `/home/git`. You
116
+
can move these paths if you'd like to store them in another folder. Be careful
117
+
when adjusting these paths:
118
+
119
+
* Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
120
+
any possible side effects. Remember to restart it once you're done.
121
+
* Make backups before moving in case something goes wrong.
122
+
* Make sure the `git` user can read and write from the new paths.
123
+
124
+
#### database
125
+
126
+
As an example, let's say the current database is at `/home/git/knotserver.db`,
127
+
and we want to move it to `/home/git/database/knotserver.db`.
128
+
129
+
Copy the current database to the new location. Make sure to copy the `.db-shm`
130
+
and `.db-wal` files if they exist.
131
+
132
+
```
133
+
mkdir /home/git/database
134
+
cp /home/git/knotserver.db* /home/git/database
135
+
```
136
+
137
+
In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to
138
+
the new file path (_not_ the directory):
139
+
140
+
```
141
+
KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db
142
+
```
143
+
144
+
#### repositories
145
+
146
+
As an example, let's say the repositories are currently in `/home/git`, and we
147
+
want to move them into `/home/git/repositories`.
148
+
149
+
Create the new folder, then move the existing repositories (if there are any):
150
+
151
+
```
152
+
mkdir /home/git/repositories
153
+
# move all DIDs into the new folder; these will vary for you!
154
+
mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories
155
+
```
156
+
157
+
In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH`
158
+
to the new directory:
159
+
160
+
```
161
+
KNOT_REPO_SCAN_PATH=/home/git/repositories
162
+
```
163
+
164
+
In your SSH config (e.g. `/etc/ssh/sshd_config.d/authorized_keys_command.conf`),
165
+
update the `AuthorizedKeysCommand` line to use the new folder. For example:
166
+
167
+
```
168
+
Match User git
169
+
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -git-dir /home/git/repositories
170
+
AuthorizedKeysCommandUser nobody
171
+
```
172
+
173
+
Make sure to restart your SSH server!
174
+
175
+
#### git
176
+
177
+
The keyfetch executable takes multiple arguments to change certain paths. You
178
+
can view a full list by running `/usr/local/libexec/tangled-keyfetch -h`.
179
+
180
+
As an example, if you wanted to change the path to the repoguard executable,
181
+
you would edit your SSH config (e.g. `/etc/ssh/sshd_config.d/authorized_keys_command.conf`)
182
+
and update the `AuthorizedKeysCommand` line:
183
+
184
+
```
185
+
Match User git
186
+
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -repoguard-path /path/to/repoguard
187
+
AuthorizedKeysCommandUser nobody
188
+
```
189
+
190
+
Make sure to restart your SSH server!
+29
-17
flake.lock
+29
-17
flake.lock
···
32
"url": "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"
33
}
34
},
35
-
"ia-fonts-src": {
36
"flake": false,
37
"locked": {
38
-
"lastModified": 1686932517,
39
-
"narHash": "sha256-2T165nFfCzO65/PIHauJA//S+zug5nUwPcg8NUEydfc=",
40
-
"owner": "iaolo",
41
-
"repo": "iA-Fonts",
42
-
"rev": "f32c04c3058a75d7ce28919ce70fe8800817491b",
43
-
"type": "github"
44
},
45
"original": {
46
-
"owner": "iaolo",
47
-
"repo": "iA-Fonts",
48
-
"type": "github"
49
}
50
},
51
"indigo": {
52
"flake": false,
53
"locked": {
54
-
"lastModified": 1738491661,
55
-
"narHash": "sha256-+njDigkvjH4XmXZMog5Mp0K4x9mamHX6gSGJCZB9mE4=",
56
"owner": "oppiliappan",
57
"repo": "indigo",
58
-
"rev": "feb802f02a462ac0a6392ffc3e40b0529f0cdf71",
59
"type": "github"
60
},
61
"original": {
···
64
"type": "github"
65
}
66
},
67
"lucide-src": {
68
"flake": false,
69
"locked": {
···
79
},
80
"nixpkgs": {
81
"locked": {
82
-
"lastModified": 1740938536,
83
-
"narHash": "sha256-m6Lz7cRoZ8GS7tziYrNWv0WXTYtKx3oOC9Bwa6a13EA=",
84
"owner": "nixos",
85
"repo": "nixpkgs",
86
-
"rev": "2ffed2bc3d27861b821f9bec127cf51a4dbfabb4",
87
"type": "github"
88
},
89
"original": {
90
"owner": "nixos",
91
"repo": "nixpkgs",
92
"type": "github"
93
}
···
96
"inputs": {
97
"gitignore": "gitignore",
98
"htmx-src": "htmx-src",
99
-
"ia-fonts-src": "ia-fonts-src",
100
"indigo": "indigo",
101
"lucide-src": "lucide-src",
102
"nixpkgs": "nixpkgs"
103
}
···
32
"url": "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"
33
}
34
},
35
+
"ibm-plex-mono-src": {
36
"flake": false,
37
"locked": {
38
+
"lastModified": 1731402384,
39
+
"narHash": "sha256-OwUmrPfEehLDz0fl2ChYLK8FQM2p0G1+EMrGsYEq+6g=",
40
+
"type": "tarball",
41
+
"url": "https://github.com/IBM/plex/releases/download/@ibm/plex-mono@1.1.0/ibm-plex-mono.zip"
42
},
43
"original": {
44
+
"type": "tarball",
45
+
"url": "https://github.com/IBM/plex/releases/download/@ibm/plex-mono@1.1.0/ibm-plex-mono.zip"
46
}
47
},
48
"indigo": {
49
"flake": false,
50
"locked": {
51
+
"lastModified": 1745333930,
52
+
"narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=",
53
"owner": "oppiliappan",
54
"repo": "indigo",
55
+
"rev": "e4e59280737b8676611fc077a228d47b3e8e9491",
56
"type": "github"
57
},
58
"original": {
···
61
"type": "github"
62
}
63
},
64
+
"inter-fonts-src": {
65
+
"flake": false,
66
+
"locked": {
67
+
"lastModified": 1731687360,
68
+
"narHash": "sha256-5vdKKvHAeZi6igrfpbOdhZlDX2/5+UvzlnCQV6DdqoQ=",
69
+
"type": "tarball",
70
+
"url": "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"
71
+
},
72
+
"original": {
73
+
"type": "tarball",
74
+
"url": "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"
75
+
}
76
+
},
77
"lucide-src": {
78
"flake": false,
79
"locked": {
···
89
},
90
"nixpkgs": {
91
"locked": {
92
+
"lastModified": 1746904237,
93
+
"narHash": "sha256-3e+AVBczosP5dCLQmMoMEogM57gmZ2qrVSrmq9aResQ=",
94
"owner": "nixos",
95
"repo": "nixpkgs",
96
+
"rev": "d89fc19e405cb2d55ce7cc114356846a0ee5e956",
97
"type": "github"
98
},
99
"original": {
100
"owner": "nixos",
101
+
"ref": "nixos-unstable",
102
"repo": "nixpkgs",
103
"type": "github"
104
}
···
107
"inputs": {
108
"gitignore": "gitignore",
109
"htmx-src": "htmx-src",
110
+
"ibm-plex-mono-src": "ibm-plex-mono-src",
111
"indigo": "indigo",
112
+
"inter-fonts-src": "inter-fonts-src",
113
"lucide-src": "lucide-src",
114
"nixpkgs": "nixpkgs"
115
}
+85
-35
flake.nix
+85
-35
flake.nix
···
2
description = "atproto github";
3
4
inputs = {
5
-
nixpkgs.url = "github:nixos/nixpkgs";
6
indigo = {
7
url = "github:oppiliappan/indigo";
8
flake = false;
···
15
url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip";
16
flake = false;
17
};
18
-
ia-fonts-src = {
19
-
url = "github:iaolo/iA-Fonts";
20
flake = false;
21
};
22
gitignore = {
···
32
htmx-src,
33
lucide-src,
34
gitignore,
35
-
ia-fonts-src,
36
}: let
37
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
38
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
···
44
inherit (gitignore.lib) gitignoreSource;
45
in {
46
overlays.default = final: prev: let
47
-
goModHash = "sha256-3gmXhututsJTFVPQi2uekTBP/qSJGgsDsVr7YU+z7d0=";
48
buildCmdPackage = name:
49
final.buildGoModule {
50
pname = name;
···
74
mkdir -p appview/pages/static/{fonts,icons}
75
cp -f ${htmx-src} appview/pages/static/htmx.min.js
76
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
77
-
cp -f ${ia-fonts-src}/"iA Writer Quattro"/Static/*.ttf appview/pages/static/fonts/
78
-
cp -f ${ia-fonts-src}/"iA Writer Mono"/Static/*.ttf appview/pages/static/fonts/
79
${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css
80
popd
81
'';
···
117
};
118
repoguard = buildCmdPackage "repoguard";
119
keyfetch = buildCmdPackage "keyfetch";
120
};
121
packages = forAllSystems (system: {
122
inherit
···
127
knotserver-unwrapped
128
repoguard
129
keyfetch
130
;
131
});
132
defaultPackage = forAllSystems (system: nixpkgsFor.${system}.appview);
···
153
mkdir -p appview/pages/static/{fonts,icons}
154
cp -f ${htmx-src} appview/pages/static/htmx.min.js
155
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
156
-
cp -f ${ia-fonts-src}/"iA Writer Quattro"/Static/*.ttf appview/pages/static/fonts/
157
-
cp -f ${ia-fonts-src}/"iA Writer Mono"/Static/*.ttf appview/pages/static/fonts/
158
'';
159
};
160
});
161
apps = forAllSystems (system: let
···
164
pkgs.writeShellScriptBin "run"
165
''
166
${pkgs.air}/bin/air -c /dev/null \
167
-
-build.cmd "${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o ./appview/pages/static/tw.css && ${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
168
-build.bin "./out/${name}.out" \
169
-
-build.include_ext "go,html,css"
170
'';
171
in {
172
watch-appview = {
···
176
watch-knotserver = {
177
type = "app";
178
program = ''${air-watcher "knotserver"}/bin/run'';
179
};
180
});
181
···
230
pkgs,
231
lib,
232
...
233
-
}:
234
with lib; {
235
options = {
236
services.tangled-knotserver = {
···
252
description = "User that hosts git repos and performs git operations";
253
};
254
255
repo = {
256
scanPath = mkOption {
257
type = types.path;
258
-
default = "/home/git";
259
description = "Path where repositories are scanned from";
260
};
261
···
287
288
dbPath = mkOption {
289
type = types.path;
290
-
default = "knotserver.db";
291
description = "Path to the database file";
292
};
293
···
306
};
307
};
308
309
-
config = mkIf config.services.tangled-knotserver.enable {
310
environment.systemPackages = with pkgs; [git];
311
312
system.activationScripts.gitConfig = ''
313
-
mkdir -p /home/git/.config/git
314
-
cat > /home/git/.config/git/config << EOF
315
[user]
316
name = Git User
317
email = git@example.com
318
EOF
319
-
chown -R git:git /home/git/.config
320
'';
321
322
-
users.users.git = {
323
-
isNormalUser = true;
324
-
home = "/home/git";
325
createHome = true;
326
-
group = "git";
327
};
328
329
-
users.groups.git = {};
330
331
services.openssh = {
332
enable = true;
333
extraConfig = ''
334
-
Match User git
335
AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper
336
AuthorizedKeysCommandUser nobody
337
'';
···
343
#!${pkgs.stdenv.shell}
344
${self.packages.${pkgs.system}.keyfetch}/bin/keyfetch \
345
-repoguard-path ${self.packages.${pkgs.system}.repoguard}/bin/repoguard \
346
-log-path /tmp/repoguard.log
347
'';
348
};
···
352
after = ["network.target" "sshd.service"];
353
wantedBy = ["multi-user.target"];
354
serviceConfig = {
355
-
User = "git";
356
-
WorkingDirectory = "/home/git";
357
Environment = [
358
-
"KNOT_REPO_SCAN_PATH=${config.services.tangled-knotserver.repo.scanPath}"
359
-
"APPVIEW_ENDPOINT=${config.services.tangled-knotserver.appviewEndpoint}"
360
-
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${config.services.tangled-knotserver.server.internalListenAddr}"
361
-
"KNOT_SERVER_LISTEN_ADDR=${config.services.tangled-knotserver.server.listenAddr}"
362
-
"KNOT_SERVER_HOSTNAME=${config.services.tangled-knotserver.server.hostname}"
363
];
364
-
EnvironmentFile = config.services.tangled-knotserver.server.secretFile;
365
ExecStart = "${self.packages.${pkgs.system}.knotserver}/bin/knotserver";
366
Restart = "always";
367
};
368
};
369
370
-
networking.firewall.allowedTCPPorts = [22];
371
};
372
};
373
···
381
...
382
}: {
383
virtualisation.memorySize = 2048;
384
virtualisation.cores = 2;
385
services.getty.autologinUser = "root";
386
environment.systemPackages = with pkgs; [curl vim git];
387
-
systemd.tmpfiles.rules = [
388
-
"w /var/lib/knotserver/secret 0660 git git - KNOT_SERVER_SECRET=6995e040e80e2d593b5e5e9ca611a70140b9ef8044add0a28b48b1ee34aa3e85"
389
];
390
services.tangled-knotserver = {
391
enable = true;
···
2
description = "atproto github";
3
4
inputs = {
5
+
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
6
indigo = {
7
url = "github:oppiliappan/indigo";
8
flake = false;
···
15
url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip";
16
flake = false;
17
};
18
+
inter-fonts-src = {
19
+
url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip";
20
+
flake = false;
21
+
};
22
+
ibm-plex-mono-src = {
23
+
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
24
flake = false;
25
};
26
gitignore = {
···
36
htmx-src,
37
lucide-src,
38
gitignore,
39
+
inter-fonts-src,
40
+
ibm-plex-mono-src,
41
}: let
42
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
43
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
···
49
inherit (gitignore.lib) gitignoreSource;
50
in {
51
overlays.default = final: prev: let
52
+
goModHash = "sha256-zcfTNo7QsiihzLa4qHEX8uGGtbcmBn8TlSm0YHBRNw8=";
53
buildCmdPackage = name:
54
final.buildGoModule {
55
pname = name;
···
79
mkdir -p appview/pages/static/{fonts,icons}
80
cp -f ${htmx-src} appview/pages/static/htmx.min.js
81
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
82
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
83
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
84
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
85
${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css
86
popd
87
'';
···
123
};
124
repoguard = buildCmdPackage "repoguard";
125
keyfetch = buildCmdPackage "keyfetch";
126
+
genjwks = buildCmdPackage "genjwks";
127
};
128
packages = forAllSystems (system: {
129
inherit
···
134
knotserver-unwrapped
135
repoguard
136
keyfetch
137
+
genjwks
138
;
139
});
140
defaultPackage = forAllSystems (system: nixpkgsFor.${system}.appview);
···
161
mkdir -p appview/pages/static/{fonts,icons}
162
cp -f ${htmx-src} appview/pages/static/htmx.min.js
163
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
164
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
165
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
166
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
167
+
export TANGLED_OAUTH_JWKS="$(${pkgs.genjwks}/bin/genjwks)"
168
'';
169
+
env.CGO_ENABLED = 1;
170
};
171
});
172
apps = forAllSystems (system: let
···
175
pkgs.writeShellScriptBin "run"
176
''
177
${pkgs.air}/bin/air -c /dev/null \
178
+
-build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
179
-build.bin "./out/${name}.out" \
180
+
-build.stop_on_error "true" \
181
+
-build.include_ext "go"
182
+
'';
183
+
tailwind-watcher =
184
+
pkgs.writeShellScriptBin "run"
185
+
''
186
+
${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css
187
'';
188
in {
189
watch-appview = {
···
193
watch-knotserver = {
194
type = "app";
195
program = ''${air-watcher "knotserver"}/bin/run'';
196
+
};
197
+
watch-tailwind = {
198
+
type = "app";
199
+
program = ''${tailwind-watcher}/bin/run'';
200
};
201
});
202
···
251
pkgs,
252
lib,
253
...
254
+
}: let
255
+
cfg = config.services.tangled-knotserver;
256
+
in
257
with lib; {
258
options = {
259
services.tangled-knotserver = {
···
275
description = "User that hosts git repos and performs git operations";
276
};
277
278
+
openFirewall = mkOption {
279
+
type = types.bool;
280
+
default = true;
281
+
description = "Open port 22 in the firewall for ssh";
282
+
};
283
+
284
+
stateDir = mkOption {
285
+
type = types.path;
286
+
default = "/home/${cfg.gitUser}";
287
+
description = "Tangled knot data directory";
288
+
};
289
+
290
repo = {
291
scanPath = mkOption {
292
type = types.path;
293
+
default = cfg.stateDir;
294
description = "Path where repositories are scanned from";
295
};
296
···
322
323
dbPath = mkOption {
324
type = types.path;
325
+
default = "${cfg.stateDir}/knotserver.db";
326
description = "Path to the database file";
327
};
328
···
341
};
342
};
343
344
+
config = mkIf cfg.enable {
345
environment.systemPackages = with pkgs; [git];
346
347
system.activationScripts.gitConfig = ''
348
+
mkdir -p "${cfg.repo.scanPath}"
349
+
chown -R ${cfg.gitUser}:${cfg.gitUser} \
350
+
"${cfg.repo.scanPath}"
351
+
352
+
mkdir -p "${cfg.stateDir}/.config/git"
353
+
cat > "${cfg.stateDir}/.config/git/config" << EOF
354
[user]
355
name = Git User
356
email = git@example.com
357
EOF
358
+
chown -R ${cfg.gitUser}:${cfg.gitUser} \
359
+
"${cfg.stateDir}"
360
'';
361
362
+
users.users.${cfg.gitUser} = {
363
+
isSystemUser = true;
364
+
useDefaultShell = true;
365
+
home = cfg.stateDir;
366
createHome = true;
367
+
group = cfg.gitUser;
368
};
369
370
+
users.groups.${cfg.gitUser} = {};
371
372
services.openssh = {
373
enable = true;
374
extraConfig = ''
375
+
Match User ${cfg.gitUser}
376
AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper
377
AuthorizedKeysCommandUser nobody
378
'';
···
384
#!${pkgs.stdenv.shell}
385
${self.packages.${pkgs.system}.keyfetch}/bin/keyfetch \
386
-repoguard-path ${self.packages.${pkgs.system}.repoguard}/bin/repoguard \
387
+
-internal-api "http://${cfg.server.internalListenAddr}" \
388
+
-git-dir "${cfg.repo.scanPath}" \
389
-log-path /tmp/repoguard.log
390
'';
391
};
···
395
after = ["network.target" "sshd.service"];
396
wantedBy = ["multi-user.target"];
397
serviceConfig = {
398
+
User = cfg.gitUser;
399
+
WorkingDirectory = cfg.stateDir;
400
Environment = [
401
+
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
402
+
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
403
+
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
404
+
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
405
+
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
406
+
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
407
+
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
408
];
409
+
EnvironmentFile = cfg.server.secretFile;
410
ExecStart = "${self.packages.${pkgs.system}.knotserver}/bin/knotserver";
411
Restart = "always";
412
};
413
};
414
415
+
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [22];
416
};
417
};
418
···
426
...
427
}: {
428
virtualisation.memorySize = 2048;
429
+
virtualisation.diskSize = 10 * 1024;
430
virtualisation.cores = 2;
431
services.getty.autologinUser = "root";
432
environment.systemPackages = with pkgs; [curl vim git];
433
+
systemd.tmpfiles.rules = let
434
+
u = config.services.tangled-knotserver.gitUser;
435
+
g = config.services.tangled-knotserver.gitUser;
436
+
in [
437
+
"d /var/lib/knotserver 0770 ${u} ${g} - -" # Create the directory first
438
+
"f+ /var/lib/knotserver/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=679f15000084699abc6a20d3ef449efa3656583f38e456a08f0638250688ff2e"
439
];
440
services.tangled-knotserver = {
441
enable = true;
+22
-16
go.mod
+22
-16
go.mod
···
1
module tangled.sh/tangled.sh/core
2
3
-
go 1.23.0
4
5
-
toolchain go1.23.6
6
7
require (
8
github.com/Blank-Xu/sql-adapter v1.1.1
9
github.com/alecthomas/chroma/v2 v2.15.0
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
-
github.com/bluesky-social/indigo v0.0.0-20250123072624-9e3b84fdbb20
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
github.com/casbin/casbin/v2 v2.103.0
14
github.com/cyphar/filepath-securejoin v0.4.1
···
19
github.com/go-git/go-git/v5 v5.14.0
20
github.com/google/uuid v1.6.0
21
github.com/gorilla/sessions v1.4.0
22
-
github.com/ipfs/go-cid v0.4.1
23
github.com/mattn/go-sqlite3 v1.14.24
24
github.com/microcosm-cc/bluemonday v1.0.27
25
github.com/resend/resend-go/v2 v2.15.0
26
github.com/sethvargo/go-envconfig v1.1.0
27
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
28
github.com/yuin/goldmark v1.4.13
29
-
golang.org/x/crypto v0.36.0
30
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
31
)
32
···
42
github.com/casbin/govaluate v1.3.0 // indirect
43
github.com/cespare/xxhash/v2 v2.3.0 // indirect
44
github.com/cloudflare/circl v1.6.0 // indirect
45
-
github.com/davecgh/go-spew v1.1.1 // indirect
46
github.com/dlclark/regexp2 v1.11.5 // indirect
47
github.com/emirpasic/gods v1.18.1 // indirect
48
github.com/felixge/httpsnoop v1.0.4 // indirect
49
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
50
github.com/go-git/go-billy/v5 v5.6.2 // indirect
51
-
github.com/go-logr/logr v1.4.1 // indirect
52
github.com/go-logr/stdr v1.2.2 // indirect
53
github.com/goccy/go-json v0.10.2 // indirect
54
github.com/gogo/protobuf v1.3.2 // indirect
55
github.com/gorilla/css v1.0.1 // indirect
56
github.com/gorilla/securecookie v1.1.2 // indirect
57
github.com/gorilla/websocket v1.5.1 // indirect
···
76
github.com/kevinburke/ssh_config v1.2.0 // indirect
77
github.com/klauspost/compress v1.17.9 // indirect
78
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
79
github.com/mattn/go-isatty v0.0.20 // indirect
80
github.com/minio/sha256-simd v1.0.1 // indirect
81
github.com/mr-tron/base58 v1.2.0 // indirect
···
87
github.com/opentracing/opentracing-go v1.2.0 // indirect
88
github.com/pjbgf/sha1cd v0.3.2 // indirect
89
github.com/pkg/errors v0.9.1 // indirect
90
-
github.com/pmezard/go-difflib v1.0.0 // indirect
91
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
92
github.com/prometheus/client_golang v1.19.1 // indirect
93
github.com/prometheus/client_model v0.6.1 // indirect
94
github.com/prometheus/common v0.54.0 // indirect
95
github.com/prometheus/procfs v0.15.1 // indirect
96
github.com/sergi/go-diff v1.3.1 // indirect
97
github.com/skeema/knownhosts v1.3.1 // indirect
98
github.com/spaolacci/murmur3 v1.1.0 // indirect
99
-
github.com/stretchr/testify v1.10.0 // indirect
100
github.com/xanzy/ssh-agent v0.3.3 // indirect
101
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
102
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
103
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
104
-
go.opentelemetry.io/otel v1.21.0 // indirect
105
-
go.opentelemetry.io/otel/metric v1.21.0 // indirect
106
-
go.opentelemetry.io/otel/trace v1.21.0 // indirect
107
go.uber.org/atomic v1.11.0 // indirect
108
go.uber.org/multierr v1.11.0 // indirect
109
go.uber.org/zap v1.26.0 // indirect
110
-
golang.org/x/net v0.37.0 // indirect
111
-
golang.org/x/sys v0.31.0 // indirect
112
-
golang.org/x/time v0.5.0 // indirect
113
google.golang.org/protobuf v1.34.2 // indirect
114
gopkg.in/warnings.v0 v0.1.2 // indirect
115
-
gopkg.in/yaml.v3 v3.0.1 // indirect
116
lukechampine.com/blake3 v1.2.1 // indirect
117
)
118
···
1
module tangled.sh/tangled.sh/core
2
3
+
go 1.24.0
4
5
+
toolchain go1.24.3
6
7
require (
8
github.com/Blank-Xu/sql-adapter v1.1.1
9
github.com/alecthomas/chroma/v2 v2.15.0
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
+
github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
github.com/casbin/casbin/v2 v2.103.0
14
github.com/cyphar/filepath-securejoin v0.4.1
···
19
github.com/go-git/go-git/v5 v5.14.0
20
github.com/google/uuid v1.6.0
21
github.com/gorilla/sessions v1.4.0
22
+
github.com/haileyok/atproto-oauth-golang v0.0.2
23
+
github.com/ipfs/go-cid v0.5.0
24
+
github.com/lestrrat-go/jwx/v2 v2.0.12
25
github.com/mattn/go-sqlite3 v1.14.24
26
github.com/microcosm-cc/bluemonday v1.0.27
27
github.com/resend/resend-go/v2 v2.15.0
28
github.com/sethvargo/go-envconfig v1.1.0
29
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
30
github.com/yuin/goldmark v1.4.13
31
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
32
)
33
···
43
github.com/casbin/govaluate v1.3.0 // indirect
44
github.com/cespare/xxhash/v2 v2.3.0 // indirect
45
github.com/cloudflare/circl v1.6.0 // indirect
46
+
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
47
github.com/dlclark/regexp2 v1.11.5 // indirect
48
github.com/emirpasic/gods v1.18.1 // indirect
49
github.com/felixge/httpsnoop v1.0.4 // indirect
50
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
51
github.com/go-git/go-billy/v5 v5.6.2 // indirect
52
+
github.com/go-logr/logr v1.4.2 // indirect
53
github.com/go-logr/stdr v1.2.2 // indirect
54
github.com/goccy/go-json v0.10.2 // indirect
55
github.com/gogo/protobuf v1.3.2 // indirect
56
+
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
57
github.com/gorilla/css v1.0.1 // indirect
58
github.com/gorilla/securecookie v1.1.2 // indirect
59
github.com/gorilla/websocket v1.5.1 // indirect
···
78
github.com/kevinburke/ssh_config v1.2.0 // indirect
79
github.com/klauspost/compress v1.17.9 // indirect
80
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
81
+
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
82
+
github.com/lestrrat-go/httpcc v1.0.1 // indirect
83
+
github.com/lestrrat-go/httprc v1.0.4 // indirect
84
+
github.com/lestrrat-go/iter v1.0.2 // indirect
85
+
github.com/lestrrat-go/option v1.0.1 // indirect
86
github.com/mattn/go-isatty v0.0.20 // indirect
87
github.com/minio/sha256-simd v1.0.1 // indirect
88
github.com/mr-tron/base58 v1.2.0 // indirect
···
94
github.com/opentracing/opentracing-go v1.2.0 // indirect
95
github.com/pjbgf/sha1cd v0.3.2 // indirect
96
github.com/pkg/errors v0.9.1 // indirect
97
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
98
github.com/prometheus/client_golang v1.19.1 // indirect
99
github.com/prometheus/client_model v0.6.1 // indirect
100
github.com/prometheus/common v0.54.0 // indirect
101
github.com/prometheus/procfs v0.15.1 // indirect
102
+
github.com/segmentio/asm v1.2.0 // indirect
103
github.com/sergi/go-diff v1.3.1 // indirect
104
github.com/skeema/knownhosts v1.3.1 // indirect
105
github.com/spaolacci/murmur3 v1.1.0 // indirect
106
github.com/xanzy/ssh-agent v0.3.3 // indirect
107
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
108
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
109
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
110
+
go.opentelemetry.io/otel v1.29.0 // indirect
111
+
go.opentelemetry.io/otel/metric v1.29.0 // indirect
112
+
go.opentelemetry.io/otel/trace v1.29.0 // indirect
113
go.uber.org/atomic v1.11.0 // indirect
114
go.uber.org/multierr v1.11.0 // indirect
115
go.uber.org/zap v1.26.0 // indirect
116
+
golang.org/x/crypto v0.37.0 // indirect
117
+
golang.org/x/net v0.39.0 // indirect
118
+
golang.org/x/sys v0.32.0 // indirect
119
+
golang.org/x/time v0.8.0 // indirect
120
google.golang.org/protobuf v1.34.2 // indirect
121
gopkg.in/warnings.v0 v0.1.2 // indirect
122
lukechampine.com/blake3 v1.2.1 // indirect
123
)
124
+73
-28
go.sum
+73
-28
go.sum
···
26
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
27
github.com/bluekeyes/go-gitdiff v0.8.1 h1:lL1GofKMywO17c0lgQmJYcKek5+s8X6tXVNOLxy4smI=
28
github.com/bluekeyes/go-gitdiff v0.8.1/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
29
-
github.com/bluesky-social/indigo v0.0.0-20250123072624-9e3b84fdbb20 h1:yHusfYYi8odoCcsI6AurU+dRWb7itHAQNwt3/Rl9Vfs=
30
-
github.com/bluesky-social/indigo v0.0.0-20250123072624-9e3b84fdbb20/go.mod h1:Qp4YqWf+AQ3TwQCxV5Ls8O2tXE55zVTGVs3zTmn7BOg=
31
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
32
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
33
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
52
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
53
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
54
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
55
-
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
56
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
57
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
58
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
59
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
···
82
github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk=
83
github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8=
84
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
85
-
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
86
-
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
87
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
88
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
89
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
···
91
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
92
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
93
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
94
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
95
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
96
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
···
111
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
112
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
113
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
114
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
115
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
116
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
···
130
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
131
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
132
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
133
-
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
134
-
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
135
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
136
github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8=
137
github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk=
···
159
github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
160
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
161
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
162
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
163
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
164
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
···
177
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
178
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
179
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
180
github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
181
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
182
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
···
212
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
213
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
214
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
215
-
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
216
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
217
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
218
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
219
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
···
227
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
228
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
229
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
230
-
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
231
-
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
232
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
233
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
234
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
235
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
···
246
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
247
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
248
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
249
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
250
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
251
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
252
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
253
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
254
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
255
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
···
270
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
271
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24=
272
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
273
-
go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc=
274
-
go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo=
275
-
go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4=
276
-
go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM=
277
-
go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc=
278
-
go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ=
279
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
280
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
281
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
···
303
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
304
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
305
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
306
-
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
307
-
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
308
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
309
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
310
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
···
314
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
315
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
316
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
317
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
318
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
319
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
···
327
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
328
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
329
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
330
-
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
331
-
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
332
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
333
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
334
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
335
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
336
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
337
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
338
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
339
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
348
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
349
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
350
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
351
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
352
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
353
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
357
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
358
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
359
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
360
-
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
361
-
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
362
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
363
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
364
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
365
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
366
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
367
-
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
368
-
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
369
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
370
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
371
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
372
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
373
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
374
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
375
-
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
376
-
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
377
-
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
378
-
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
379
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
380
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
381
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
389
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
390
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
391
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
392
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
393
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
394
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
···
26
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
27
github.com/bluekeyes/go-gitdiff v0.8.1 h1:lL1GofKMywO17c0lgQmJYcKek5+s8X6tXVNOLxy4smI=
28
github.com/bluekeyes/go-gitdiff v0.8.1/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
29
+
github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188 h1:1sQaG37xk08/rpmdhrmMkfQWF9kZbnfHm9Zav3bbSMk=
30
+
github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188/go.mod h1:NVBwZvbBSa93kfyweAmKwOLYawdVHdwZ9s+GZtBBVLA=
31
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
32
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
33
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
52
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
53
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
54
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
55
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
56
+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
57
+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
58
+
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
59
+
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
60
+
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
61
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
62
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
63
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
···
86
github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk=
87
github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8=
88
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
89
+
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
90
+
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
91
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
92
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
93
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
···
95
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
96
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
97
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
98
+
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
99
+
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
100
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
101
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
102
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
···
117
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
118
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
119
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
120
+
github.com/haileyok/atproto-oauth-golang v0.0.2 h1:61KPkLB615LQXR2f5x1v3sf6vPe6dOXqNpTYCgZ0Fz8=
121
+
github.com/haileyok/atproto-oauth-golang v0.0.2/go.mod h1:jcZ4GCjo5I5RuE/RsAXg1/b6udw7R4W+2rb/cGyTDK8=
122
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
123
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
124
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
···
138
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
139
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
140
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
141
+
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
142
+
github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
143
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
144
github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8=
145
github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk=
···
167
github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
168
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
169
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
170
+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
171
+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
172
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
173
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
174
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
···
187
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
188
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
189
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
190
+
github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
191
+
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
192
+
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
193
+
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
194
+
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
195
+
github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8=
196
+
github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
197
+
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
198
+
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
199
+
github.com/lestrrat-go/jwx/v2 v2.0.12 h1:3d589+5w/b9b7S3DneICPW16AqTyYXB7VRjgluSDWeA=
200
+
github.com/lestrrat-go/jwx/v2 v2.0.12/go.mod h1:Mq4KN1mM7bp+5z/W5HS8aCNs5RKZ911G/0y2qUjAQuQ=
201
+
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
202
+
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
203
+
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
204
github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
205
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
206
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
···
236
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
237
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
238
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
239
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
240
+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
241
+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
242
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
243
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
244
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
···
252
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
253
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
254
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
255
+
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
256
+
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
257
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
258
+
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
259
+
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
260
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
261
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
262
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
···
273
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
274
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
275
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
276
+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
277
+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
278
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
279
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
280
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
281
+
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
282
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
283
+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
284
+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
285
+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
286
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
287
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
288
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
···
303
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
304
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24=
305
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
306
+
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
307
+
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
308
+
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
309
+
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
310
+
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
311
+
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
312
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
313
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
314
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
···
336
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
337
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
338
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
339
+
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
340
+
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
341
+
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
342
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
343
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
344
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
···
348
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
349
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
350
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
351
+
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
352
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
353
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
354
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
···
362
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
363
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
364
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
365
+
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
366
+
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
367
+
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
368
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
369
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
370
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
371
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
372
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
373
+
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
374
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
375
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
376
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
385
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
386
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
387
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
388
+
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
389
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
390
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
391
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
395
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
396
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
397
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
398
+
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
399
+
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
400
+
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
401
+
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
402
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
403
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
404
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
405
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
406
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
407
+
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
408
+
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
409
+
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
410
+
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
411
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
412
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
413
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
414
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
415
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
416
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
417
+
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
418
+
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
419
+
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
420
+
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
421
+
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
422
+
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
423
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
424
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
425
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
433
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
434
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
435
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
436
+
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
437
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
438
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
439
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+715
-87
input.css
+715
-87
input.css
···
3
@tailwind utilities;
4
@layer base {
5
@font-face {
6
-
font-family: "iA Writer Quattro S";
7
-
src: url("/static/fonts/iAWriterQuattroS-Regular.ttf")
8
-
format("truetype");
9
font-weight: normal;
10
font-style: normal;
11
font-display: swap;
12
-
font-feature-settings:
13
-
"calt" 1,
14
-
"kern" 1;
15
}
16
-
@font-face {
17
-
font-family: "iA Writer Quattro S";
18
-
src: url("/static/fonts/iAWriterQuattroS-Bold.ttf") format("truetype");
19
-
font-weight: bold;
20
-
font-style: normal;
21
-
font-display: swap;
22
-
font-feature-settings:
23
-
"calt" 1,
24
-
"kern" 1;
25
-
}
26
@font-face {
27
-
font-family: "iA Writer Quattro S";
28
-
src: url("/static/fonts/iAWriterQuattroS-Italic.ttf") format("truetype");
29
-
font-weight: normal;
30
font-style: italic;
31
font-display: swap;
32
-
font-feature-settings:
33
-
"calt" 1,
34
-
"kern" 1;
35
-
}
36
-
@font-face {
37
-
font-family: "iA Writer Quattro S";
38
-
src: url("/static/fonts/iAWriterQuattroS-BoldItalic.ttf")
39
-
format("truetype");
40
-
font-weight: bold;
41
-
font-style: italic;
42
-
font-display: swap;
43
-
font-feature-settings:
44
-
"calt" 1,
45
-
"kern" 1;
46
}
47
48
@font-face {
49
-
font-family: "iA Writer Mono S";
50
-
src: url("/static/fonts/iAWriterMonoS-Regular.ttf") format("truetype");
51
-
font-weight: normal;
52
font-style: normal;
53
font-display: swap;
54
-
font-feature-settings:
55
-
"calt" 1,
56
-
"kern" 1;
57
}
58
@font-face {
59
-
font-family: "iA Writer Mono S";
60
-
src: url("/static/fonts/iAWriterMonoS-Bold.ttf") format("truetype");
61
-
font-weight: bold;
62
-
font-style: normal;
63
-
font-display: swap;
64
-
font-feature-settings:
65
-
"calt" 1,
66
-
"kern" 1;
67
-
}
68
-
@font-face {
69
-
font-family: "iA Writer Mono S";
70
-
src: url("/static/fonts/iAWriterMonoS-Italic.ttf") format("truetype");
71
font-weight: normal;
72
font-style: italic;
73
font-display: swap;
74
-
font-feature-settings:
75
-
"calt" 1,
76
-
"kern" 1;
77
-
}
78
-
@font-face {
79
-
font-family: "iA Writer Mono S";
80
-
src: url("/static/fonts/iAWriterMonoS-BoldItalic.ttf")
81
-
format("truetype");
82
-
font-weight: bold;
83
-
font-style: italic;
84
-
font-display: swap;
85
-
font-feature-settings:
86
-
"calt" 1,
87
-
"kern" 1;
88
-
}
89
-
90
-
@font-face {
91
-
font-family: "Inter";
92
-
font-style: normal;
93
-
font-weight: 400;
94
-
font-display: swap;
95
-
font-feature-settings:
96
-
"calt" 1,
97
-
"kern" 1;
98
}
99
100
::selection {
101
-
@apply bg-yellow-400;
102
-
@apply text-black;
103
-
@apply bg-opacity-30;
104
}
105
106
@layer base {
107
html {
108
-
letter-spacing: -0.01em;
109
-
word-spacing: -0.07em;
110
font-size: 14px;
111
}
112
a {
113
-
@apply no-underline text-black hover:underline hover:text-gray-800;
114
}
115
116
label {
117
-
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase;
118
}
119
input {
120
-
@apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3;
121
}
122
textarea {
123
-
@apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3;
124
}
125
details summary::-webkit-details-marker {
126
display: none;
···
141
focus-visible:before:outline-4 focus-visible:before:outline-gray-500
142
active:before:shadow-[inset_0_2px_2px_0_rgba(20,20,96,0.1)]
143
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:before:border-gray-200
144
-
disabled:hover:before:bg-white disabled:hover:before:shadow-none;
145
}
146
}
147
@layer utilities {
148
.error {
149
-
@apply py-1 text-red-400;
150
}
151
.success {
152
-
@apply py-1 text-gray-900;
153
}
154
}
155
}
···
3
@tailwind utilities;
4
@layer base {
5
@font-face {
6
+
font-family: "InterVariable";
7
+
src: url("/static/fonts/InterVariable.woff2") format("woff2");
8
font-weight: normal;
9
font-style: normal;
10
font-display: swap;
11
}
12
+
13
@font-face {
14
+
font-family: "InterVariable";
15
+
src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2");
16
+
font-weight: 400;
17
font-style: italic;
18
font-display: swap;
19
}
20
21
@font-face {
22
+
font-family: "InterVariable";
23
+
src: url("/static/fonts/InterVariable.woff2") format("woff2");
24
+
font-weight: 600;
25
font-style: normal;
26
font-display: swap;
27
}
28
+
29
@font-face {
30
+
font-family: "IBMPlexMono";
31
+
src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2");
32
font-weight: normal;
33
font-style: italic;
34
font-display: swap;
35
}
36
37
::selection {
38
+
@apply bg-yellow-400 text-black bg-opacity-30 dark:bg-yellow-600 dark:bg-opacity-50 dark:text-white;
39
}
40
41
@layer base {
42
html {
43
font-size: 14px;
44
}
45
+
@supports (font-variation-settings: normal) {
46
+
html {
47
+
font-feature-settings:
48
+
"ss01" 1,
49
+
"kern" 1,
50
+
"liga" 1,
51
+
"cv05" 1,
52
+
"tnum" 1;
53
+
}
54
+
}
55
+
56
a {
57
+
@apply no-underline text-black hover:underline hover:text-gray-800 dark:text-white dark:hover:text-gray-300;
58
}
59
60
label {
61
+
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
62
}
63
input {
64
+
@apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
65
}
66
textarea {
67
+
@apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
68
}
69
details summary::-webkit-details-marker {
70
display: none;
···
85
focus-visible:before:outline-4 focus-visible:before:outline-gray-500
86
active:before:shadow-[inset_0_2px_2px_0_rgba(20,20,96,0.1)]
87
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:before:border-gray-200
88
+
disabled:hover:before:bg-white disabled:hover:before:shadow-none
89
+
dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700
90
+
dark:hover:before:border-gray-600 dark:hover:before:bg-gray-700
91
+
dark:hover:before:shadow-[0_2px_2px_0_rgba(0,0,0,0.2),inset_0_-2px_0_0_#2d3748]
92
+
dark:focus-visible:before:outline-gray-400
93
+
dark:active:before:shadow-[inset_0_2px_2px_0_rgba(0,0,0,0.3)]
94
+
dark:disabled:hover:before:bg-gray-800 dark:disabled:hover:before:border-gray-700;
95
}
96
}
97
@layer utilities {
98
.error {
99
+
@apply py-1 text-red-400 dark:text-red-300;
100
}
101
.success {
102
+
@apply py-1 text-gray-900 dark:text-gray-100;
103
}
104
}
105
}
106
+
107
+
/* Background */
108
+
.bg {
109
+
color: #4c4f69;
110
+
background-color: #eff1f5;
111
+
}
112
+
/* PreWrapper */
113
+
.chroma {
114
+
color: #4c4f69;
115
+
background-color: #eff1f5;
116
+
}
117
+
/* Error */
118
+
.chroma .err {
119
+
color: #d20f39;
120
+
}
121
+
/* LineLink */
122
+
.chroma .lnlinks {
123
+
outline: none;
124
+
text-decoration: none;
125
+
color: inherit;
126
+
}
127
+
/* LineTableTD */
128
+
.chroma .lntd {
129
+
vertical-align: top;
130
+
padding: 0;
131
+
margin: 0;
132
+
border: 0;
133
+
}
134
+
/* LineTable */
135
+
.chroma .lntable {
136
+
border-spacing: 0;
137
+
padding: 0;
138
+
margin: 0;
139
+
border: 0;
140
+
}
141
+
/* LineHighlight */
142
+
.chroma .hl {
143
+
background-color: #bcc0cc;
144
+
}
145
+
/* LineNumbersTable */
146
+
.chroma .lnt {
147
+
white-space: pre;
148
+
-webkit-user-select: none;
149
+
user-select: none;
150
+
margin-right: 0.4em;
151
+
padding: 0 0.4em 0 0.4em;
152
+
color: #8c8fa1;
153
+
}
154
+
/* LineNumbers */
155
+
.chroma .ln {
156
+
white-space: pre;
157
+
-webkit-user-select: none;
158
+
user-select: none;
159
+
margin-right: 0.4em;
160
+
padding: 0 0.4em 0 0.4em;
161
+
color: #8c8fa1;
162
+
}
163
+
/* Line */
164
+
.chroma .line {
165
+
display: flex;
166
+
}
167
+
/* Keyword */
168
+
.chroma .k {
169
+
color: #8839ef;
170
+
}
171
+
/* KeywordConstant */
172
+
.chroma .kc {
173
+
color: #fe640b;
174
+
}
175
+
/* KeywordDeclaration */
176
+
.chroma .kd {
177
+
color: #d20f39;
178
+
}
179
+
/* KeywordNamespace */
180
+
.chroma .kn {
181
+
color: #179299;
182
+
}
183
+
/* KeywordPseudo */
184
+
.chroma .kp {
185
+
color: #8839ef;
186
+
}
187
+
/* KeywordReserved */
188
+
.chroma .kr {
189
+
color: #8839ef;
190
+
}
191
+
/* KeywordType */
192
+
.chroma .kt {
193
+
color: #d20f39;
194
+
}
195
+
/* NameAttribute */
196
+
.chroma .na {
197
+
color: #1e66f5;
198
+
}
199
+
/* NameBuiltin */
200
+
.chroma .nb {
201
+
color: #04a5e5;
202
+
}
203
+
/* NameBuiltinPseudo */
204
+
.chroma .bp {
205
+
color: #04a5e5;
206
+
}
207
+
/* NameClass */
208
+
.chroma .nc {
209
+
color: #df8e1d;
210
+
}
211
+
/* NameConstant */
212
+
.chroma .no {
213
+
color: #df8e1d;
214
+
}
215
+
/* NameDecorator */
216
+
.chroma .nd {
217
+
color: #1e66f5;
218
+
font-weight: bold;
219
+
}
220
+
/* NameEntity */
221
+
.chroma .ni {
222
+
color: #179299;
223
+
}
224
+
/* NameException */
225
+
.chroma .ne {
226
+
color: #fe640b;
227
+
}
228
+
/* NameFunction */
229
+
.chroma .nf {
230
+
color: #1e66f5;
231
+
}
232
+
/* NameFunctionMagic */
233
+
.chroma .fm {
234
+
color: #1e66f5;
235
+
}
236
+
/* NameLabel */
237
+
.chroma .nl {
238
+
color: #04a5e5;
239
+
}
240
+
/* NameNamespace */
241
+
.chroma .nn {
242
+
color: #fe640b;
243
+
}
244
+
/* NameProperty */
245
+
.chroma .py {
246
+
color: #fe640b;
247
+
}
248
+
/* NameTag */
249
+
.chroma .nt {
250
+
color: #8839ef;
251
+
}
252
+
/* NameVariable */
253
+
.chroma .nv {
254
+
color: #dc8a78;
255
+
}
256
+
/* NameVariableClass */
257
+
.chroma .vc {
258
+
color: #dc8a78;
259
+
}
260
+
/* NameVariableGlobal */
261
+
.chroma .vg {
262
+
color: #dc8a78;
263
+
}
264
+
/* NameVariableInstance */
265
+
.chroma .vi {
266
+
color: #dc8a78;
267
+
}
268
+
/* NameVariableMagic */
269
+
.chroma .vm {
270
+
color: #dc8a78;
271
+
}
272
+
/* LiteralString */
273
+
.chroma .s {
274
+
color: #40a02b;
275
+
}
276
+
/* LiteralStringAffix */
277
+
.chroma .sa {
278
+
color: #d20f39;
279
+
}
280
+
/* LiteralStringBacktick */
281
+
.chroma .sb {
282
+
color: #40a02b;
283
+
}
284
+
/* LiteralStringChar */
285
+
.chroma .sc {
286
+
color: #40a02b;
287
+
}
288
+
/* LiteralStringDelimiter */
289
+
.chroma .dl {
290
+
color: #1e66f5;
291
+
}
292
+
/* LiteralStringDoc */
293
+
.chroma .sd {
294
+
color: #9ca0b0;
295
+
}
296
+
/* LiteralStringDouble */
297
+
.chroma .s2 {
298
+
color: #40a02b;
299
+
}
300
+
/* LiteralStringEscape */
301
+
.chroma .se {
302
+
color: #1e66f5;
303
+
}
304
+
/* LiteralStringHeredoc */
305
+
.chroma .sh {
306
+
color: #9ca0b0;
307
+
}
308
+
/* LiteralStringInterpol */
309
+
.chroma .si {
310
+
color: #40a02b;
311
+
}
312
+
/* LiteralStringOther */
313
+
.chroma .sx {
314
+
color: #40a02b;
315
+
}
316
+
/* LiteralStringRegex */
317
+
.chroma .sr {
318
+
color: #179299;
319
+
}
320
+
/* LiteralStringSingle */
321
+
.chroma .s1 {
322
+
color: #40a02b;
323
+
}
324
+
/* LiteralStringSymbol */
325
+
.chroma .ss {
326
+
color: #40a02b;
327
+
}
328
+
/* LiteralNumber */
329
+
.chroma .m {
330
+
color: #fe640b;
331
+
}
332
+
/* LiteralNumberBin */
333
+
.chroma .mb {
334
+
color: #fe640b;
335
+
}
336
+
/* LiteralNumberFloat */
337
+
.chroma .mf {
338
+
color: #fe640b;
339
+
}
340
+
/* LiteralNumberHex */
341
+
.chroma .mh {
342
+
color: #fe640b;
343
+
}
344
+
/* LiteralNumberInteger */
345
+
.chroma .mi {
346
+
color: #fe640b;
347
+
}
348
+
/* LiteralNumberIntegerLong */
349
+
.chroma .il {
350
+
color: #fe640b;
351
+
}
352
+
/* LiteralNumberOct */
353
+
.chroma .mo {
354
+
color: #fe640b;
355
+
}
356
+
/* Operator */
357
+
.chroma .o {
358
+
color: #04a5e5;
359
+
font-weight: bold;
360
+
}
361
+
/* OperatorWord */
362
+
.chroma .ow {
363
+
color: #04a5e5;
364
+
font-weight: bold;
365
+
}
366
+
/* Comment */
367
+
.chroma .c {
368
+
color: #9ca0b0;
369
+
font-style: italic;
370
+
}
371
+
/* CommentHashbang */
372
+
.chroma .ch {
373
+
color: #9ca0b0;
374
+
font-style: italic;
375
+
}
376
+
/* CommentMultiline */
377
+
.chroma .cm {
378
+
color: #9ca0b0;
379
+
font-style: italic;
380
+
}
381
+
/* CommentSingle */
382
+
.chroma .c1 {
383
+
color: #9ca0b0;
384
+
font-style: italic;
385
+
}
386
+
/* CommentSpecial */
387
+
.chroma .cs {
388
+
color: #9ca0b0;
389
+
font-style: italic;
390
+
}
391
+
/* CommentPreproc */
392
+
.chroma .cp {
393
+
color: #9ca0b0;
394
+
font-style: italic;
395
+
}
396
+
/* CommentPreprocFile */
397
+
.chroma .cpf {
398
+
color: #9ca0b0;
399
+
font-weight: bold;
400
+
font-style: italic;
401
+
}
402
+
/* GenericDeleted */
403
+
.chroma .gd {
404
+
color: #d20f39;
405
+
background-color: oklch(93.6% 0.032 17.717);
406
+
}
407
+
/* GenericEmph */
408
+
.chroma .ge {
409
+
font-style: italic;
410
+
}
411
+
/* GenericError */
412
+
.chroma .gr {
413
+
color: #d20f39;
414
+
}
415
+
/* GenericHeading */
416
+
.chroma .gh {
417
+
color: #fe640b;
418
+
font-weight: bold;
419
+
}
420
+
/* GenericInserted */
421
+
.chroma .gi {
422
+
color: #40a02b;
423
+
background-color: oklch(96.2% 0.044 156.743);
424
+
}
425
+
/* GenericStrong */
426
+
.chroma .gs {
427
+
font-weight: bold;
428
+
}
429
+
/* GenericSubheading */
430
+
.chroma .gu {
431
+
color: #fe640b;
432
+
font-weight: bold;
433
+
}
434
+
/* GenericTraceback */
435
+
.chroma .gt {
436
+
color: #d20f39;
437
+
}
438
+
/* GenericUnderline */
439
+
.chroma .gl {
440
+
text-decoration: underline;
441
+
}
442
+
443
+
@media (prefers-color-scheme: dark) {
444
+
/* Background */
445
+
.bg {
446
+
color: #cad3f5;
447
+
background-color: #24273a;
448
+
}
449
+
/* PreWrapper */
450
+
.chroma {
451
+
color: #cad3f5;
452
+
background-color: #24273a;
453
+
}
454
+
/* Error */
455
+
.chroma .err {
456
+
color: #ed8796;
457
+
}
458
+
/* LineLink */
459
+
.chroma .lnlinks {
460
+
outline: none;
461
+
text-decoration: none;
462
+
color: inherit;
463
+
}
464
+
/* LineTableTD */
465
+
.chroma .lntd {
466
+
vertical-align: top;
467
+
padding: 0;
468
+
margin: 0;
469
+
border: 0;
470
+
}
471
+
/* LineTable */
472
+
.chroma .lntable {
473
+
border-spacing: 0;
474
+
padding: 0;
475
+
margin: 0;
476
+
border: 0;
477
+
}
478
+
/* LineHighlight */
479
+
.chroma .hl {
480
+
background-color: #494d64;
481
+
}
482
+
/* LineNumbersTable */
483
+
.chroma .lnt {
484
+
white-space: pre;
485
+
-webkit-user-select: none;
486
+
user-select: none;
487
+
margin-right: 0.4em;
488
+
padding: 0 0.4em 0 0.4em;
489
+
color: #8087a2;
490
+
}
491
+
/* LineNumbers */
492
+
.chroma .ln {
493
+
white-space: pre;
494
+
-webkit-user-select: none;
495
+
user-select: none;
496
+
margin-right: 0.4em;
497
+
padding: 0 0.4em 0 0.4em;
498
+
color: #8087a2;
499
+
}
500
+
/* Line */
501
+
.chroma .line {
502
+
display: flex;
503
+
}
504
+
/* Keyword */
505
+
.chroma .k {
506
+
color: #c6a0f6;
507
+
}
508
+
/* KeywordConstant */
509
+
.chroma .kc {
510
+
color: #f5a97f;
511
+
}
512
+
/* KeywordDeclaration */
513
+
.chroma .kd {
514
+
color: #ed8796;
515
+
}
516
+
/* KeywordNamespace */
517
+
.chroma .kn {
518
+
color: #8bd5ca;
519
+
}
520
+
/* KeywordPseudo */
521
+
.chroma .kp {
522
+
color: #c6a0f6;
523
+
}
524
+
/* KeywordReserved */
525
+
.chroma .kr {
526
+
color: #c6a0f6;
527
+
}
528
+
/* KeywordType */
529
+
.chroma .kt {
530
+
color: #ed8796;
531
+
}
532
+
/* NameAttribute */
533
+
.chroma .na {
534
+
color: #8aadf4;
535
+
}
536
+
/* NameBuiltin */
537
+
.chroma .nb {
538
+
color: #91d7e3;
539
+
}
540
+
/* NameBuiltinPseudo */
541
+
.chroma .bp {
542
+
color: #91d7e3;
543
+
}
544
+
/* NameClass */
545
+
.chroma .nc {
546
+
color: #eed49f;
547
+
}
548
+
/* NameConstant */
549
+
.chroma .no {
550
+
color: #eed49f;
551
+
}
552
+
/* NameDecorator */
553
+
.chroma .nd {
554
+
color: #8aadf4;
555
+
font-weight: bold;
556
+
}
557
+
/* NameEntity */
558
+
.chroma .ni {
559
+
color: #8bd5ca;
560
+
}
561
+
/* NameException */
562
+
.chroma .ne {
563
+
color: #f5a97f;
564
+
}
565
+
/* NameFunction */
566
+
.chroma .nf {
567
+
color: #8aadf4;
568
+
}
569
+
/* NameFunctionMagic */
570
+
.chroma .fm {
571
+
color: #8aadf4;
572
+
}
573
+
/* NameLabel */
574
+
.chroma .nl {
575
+
color: #91d7e3;
576
+
}
577
+
/* NameNamespace */
578
+
.chroma .nn {
579
+
color: #f5a97f;
580
+
}
581
+
/* NameProperty */
582
+
.chroma .py {
583
+
color: #f5a97f;
584
+
}
585
+
/* NameTag */
586
+
.chroma .nt {
587
+
color: #c6a0f6;
588
+
}
589
+
/* NameVariable */
590
+
.chroma .nv {
591
+
color: #f4dbd6;
592
+
}
593
+
/* NameVariableClass */
594
+
.chroma .vc {
595
+
color: #f4dbd6;
596
+
}
597
+
/* NameVariableGlobal */
598
+
.chroma .vg {
599
+
color: #f4dbd6;
600
+
}
601
+
/* NameVariableInstance */
602
+
.chroma .vi {
603
+
color: #f4dbd6;
604
+
}
605
+
/* NameVariableMagic */
606
+
.chroma .vm {
607
+
color: #f4dbd6;
608
+
}
609
+
/* LiteralString */
610
+
.chroma .s {
611
+
color: #a6da95;
612
+
}
613
+
/* LiteralStringAffix */
614
+
.chroma .sa {
615
+
color: #ed8796;
616
+
}
617
+
/* LiteralStringBacktick */
618
+
.chroma .sb {
619
+
color: #a6da95;
620
+
}
621
+
/* LiteralStringChar */
622
+
.chroma .sc {
623
+
color: #a6da95;
624
+
}
625
+
/* LiteralStringDelimiter */
626
+
.chroma .dl {
627
+
color: #8aadf4;
628
+
}
629
+
/* LiteralStringDoc */
630
+
.chroma .sd {
631
+
color: #6e738d;
632
+
}
633
+
/* LiteralStringDouble */
634
+
.chroma .s2 {
635
+
color: #a6da95;
636
+
}
637
+
/* LiteralStringEscape */
638
+
.chroma .se {
639
+
color: #8aadf4;
640
+
}
641
+
/* LiteralStringHeredoc */
642
+
.chroma .sh {
643
+
color: #6e738d;
644
+
}
645
+
/* LiteralStringInterpol */
646
+
.chroma .si {
647
+
color: #a6da95;
648
+
}
649
+
/* LiteralStringOther */
650
+
.chroma .sx {
651
+
color: #a6da95;
652
+
}
653
+
/* LiteralStringRegex */
654
+
.chroma .sr {
655
+
color: #8bd5ca;
656
+
}
657
+
/* LiteralStringSingle */
658
+
.chroma .s1 {
659
+
color: #a6da95;
660
+
}
661
+
/* LiteralStringSymbol */
662
+
.chroma .ss {
663
+
color: #a6da95;
664
+
}
665
+
/* LiteralNumber */
666
+
.chroma .m {
667
+
color: #f5a97f;
668
+
}
669
+
/* LiteralNumberBin */
670
+
.chroma .mb {
671
+
color: #f5a97f;
672
+
}
673
+
/* LiteralNumberFloat */
674
+
.chroma .mf {
675
+
color: #f5a97f;
676
+
}
677
+
/* LiteralNumberHex */
678
+
.chroma .mh {
679
+
color: #f5a97f;
680
+
}
681
+
/* LiteralNumberInteger */
682
+
.chroma .mi {
683
+
color: #f5a97f;
684
+
}
685
+
/* LiteralNumberIntegerLong */
686
+
.chroma .il {
687
+
color: #f5a97f;
688
+
}
689
+
/* LiteralNumberOct */
690
+
.chroma .mo {
691
+
color: #f5a97f;
692
+
}
693
+
/* Operator */
694
+
.chroma .o {
695
+
color: #91d7e3;
696
+
font-weight: bold;
697
+
}
698
+
/* OperatorWord */
699
+
.chroma .ow {
700
+
color: #91d7e3;
701
+
font-weight: bold;
702
+
}
703
+
/* Comment */
704
+
.chroma .c {
705
+
color: #6e738d;
706
+
font-style: italic;
707
+
}
708
+
/* CommentHashbang */
709
+
.chroma .ch {
710
+
color: #6e738d;
711
+
font-style: italic;
712
+
}
713
+
/* CommentMultiline */
714
+
.chroma .cm {
715
+
color: #6e738d;
716
+
font-style: italic;
717
+
}
718
+
/* CommentSingle */
719
+
.chroma .c1 {
720
+
color: #6e738d;
721
+
font-style: italic;
722
+
}
723
+
/* CommentSpecial */
724
+
.chroma .cs {
725
+
color: #6e738d;
726
+
font-style: italic;
727
+
}
728
+
/* CommentPreproc */
729
+
.chroma .cp {
730
+
color: #6e738d;
731
+
font-style: italic;
732
+
}
733
+
/* CommentPreprocFile */
734
+
.chroma .cpf {
735
+
color: #6e738d;
736
+
font-weight: bold;
737
+
font-style: italic;
738
+
}
739
+
/* GenericDeleted */
740
+
.chroma .gd {
741
+
color: #ed8796;
742
+
background-color: oklch(44.4% 0.177 26.899 / 0.5);
743
+
}
744
+
/* GenericEmph */
745
+
.chroma .ge {
746
+
font-style: italic;
747
+
}
748
+
/* GenericError */
749
+
.chroma .gr {
750
+
color: #ed8796;
751
+
}
752
+
/* GenericHeading */
753
+
.chroma .gh {
754
+
color: #f5a97f;
755
+
font-weight: bold;
756
+
}
757
+
/* GenericInserted */
758
+
.chroma .gi {
759
+
color: #a6da95;
760
+
background-color: oklch(44.8% 0.119 151.328 / 0.5);
761
+
}
762
+
/* GenericStrong */
763
+
.chroma .gs {
764
+
font-weight: bold;
765
+
}
766
+
/* GenericSubheading */
767
+
.chroma .gu {
768
+
color: #f5a97f;
769
+
font-weight: bold;
770
+
}
771
+
/* GenericTraceback */
772
+
.chroma .gt {
773
+
color: #ed8796;
774
+
}
775
+
/* GenericUnderline */
776
+
.chroma .gl {
777
+
text-decoration: underline;
778
+
}
779
+
}
780
+
781
+
.chroma .line:has(.ln:target) {
782
+
@apply bg-amber-400/30 dark:bg-amber-500/20;
783
+
}
+91
-24
jetstream/jetstream.go
+91
-24
jetstream/jetstream.go
···
4
"context"
5
"fmt"
6
"log/slog"
7
"sync"
8
"time"
9
10
"github.com/bluesky-social/jetstream/pkg/client"
···
16
type DB interface {
17
GetLastTimeUs() (int64, error)
18
SaveLastTimeUs(int64) error
19
-
UpdateLastTimeUs(int64) error
20
}
21
22
type JetstreamClient struct {
23
cfg *client.ClientConfig
24
client *client.Client
25
ident string
26
l *slog.Logger
27
28
db DB
29
waitForDid bool
30
mu sync.RWMutex
···
37
if did == "" {
38
return
39
}
40
j.mu.Lock()
41
-
j.cfg.WantedDids = append(j.cfg.WantedDids, did)
42
j.mu.Unlock()
43
}
44
45
-
func (j *JetstreamClient) UpdateDids(dids []string) {
46
-
j.mu.Lock()
47
-
for _, did := range dids {
48
-
if did != "" {
49
-
j.cfg.WantedDids = append(j.cfg.WantedDids, did)
50
-
}
51
-
}
52
-
j.mu.Unlock()
53
54
-
j.cancelMu.Lock()
55
-
if j.cancel != nil {
56
-
j.cancel()
57
}
58
-
j.cancelMu.Unlock()
59
}
60
61
func NewJetstreamClient(endpoint, ident string, collections []string, cfg *client.ClientConfig, logger *slog.Logger, db DB, waitForDid bool) (*JetstreamClient, error) {
···
66
}
67
68
return &JetstreamClient{
69
-
cfg: cfg,
70
-
ident: ident,
71
-
db: db,
72
-
l: logger,
73
74
// This will make the goroutine in StartJetstream wait until
75
-
// cfg.WantedDids has been populated, typically using UpdateDids.
76
waitForDid: waitForDid,
77
}, nil
78
}
79
80
// StartJetstream starts the jetstream client and processes events using the provided processFunc.
81
-
// The caller is responsible for saving the last time_us to the database (just use your db.SaveLastTimeUs).
82
func (j *JetstreamClient) StartJetstream(ctx context.Context, processFunc func(context.Context, *models.Event) error) error {
83
logger := j.l
84
85
-
sched := sequential.NewScheduler(j.ident, logger, processFunc)
86
87
client, err := client.NewClient(j.cfg, log.New("jetstream"), sched)
88
if err != nil {
···
92
93
go func() {
94
if j.waitForDid {
95
-
for len(j.cfg.WantedDids) == 0 {
96
time.Sleep(time.Second)
97
}
98
}
99
logger.Info("done waiting for did")
100
j.connectAndRead(ctx)
101
}()
102
···
130
}
131
}
132
133
func (j *JetstreamClient) getLastTimeUs(ctx context.Context) *int64 {
134
l := log.FromContext(ctx)
135
lastTimeUs, err := j.db.GetLastTimeUs()
···
142
}
143
}
144
145
-
// If last time is older than a week, start from now
146
if time.Now().UnixMicro()-lastTimeUs > 2*24*60*60*1000*1000 {
147
lastTimeUs = time.Now().UnixMicro()
148
l.Warn("last time us is older than 2 days; discarding that and starting from now")
149
-
err = j.db.UpdateLastTimeUs(lastTimeUs)
150
if err != nil {
151
l.Error("failed to save last time us", "error", err)
152
}
···
155
l.Info("found last time_us", "time_us", lastTimeUs)
156
return &lastTimeUs
157
}
···
4
"context"
5
"fmt"
6
"log/slog"
7
+
"os"
8
+
"os/signal"
9
"sync"
10
+
"syscall"
11
"time"
12
13
"github.com/bluesky-social/jetstream/pkg/client"
···
19
type DB interface {
20
GetLastTimeUs() (int64, error)
21
SaveLastTimeUs(int64) error
22
}
23
24
+
type Set[T comparable] map[T]struct{}
25
+
26
type JetstreamClient struct {
27
cfg *client.ClientConfig
28
client *client.Client
29
ident string
30
l *slog.Logger
31
32
+
wantedDids Set[string]
33
db DB
34
waitForDid bool
35
mu sync.RWMutex
···
42
if did == "" {
43
return
44
}
45
+
46
+
j.l.Info("adding did to in-memory filter", "did", did)
47
j.mu.Lock()
48
+
j.wantedDids[did] = struct{}{}
49
j.mu.Unlock()
50
}
51
52
+
type processor func(context.Context, *models.Event) error
53
54
+
func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
55
+
// empty filter => all dids allowed
56
+
if len(j.wantedDids) == 0 {
57
+
return processFunc
58
}
59
+
// since this closure references j.WantedDids; it should auto-update
60
+
// existing instances of the closure when j.WantedDids is mutated
61
+
return func(ctx context.Context, evt *models.Event) error {
62
+
if _, ok := j.wantedDids[evt.Did]; ok {
63
+
return processFunc(ctx, evt)
64
+
} else {
65
+
return nil
66
+
}
67
+
}
68
}
69
70
func NewJetstreamClient(endpoint, ident string, collections []string, cfg *client.ClientConfig, logger *slog.Logger, db DB, waitForDid bool) (*JetstreamClient, error) {
···
75
}
76
77
return &JetstreamClient{
78
+
cfg: cfg,
79
+
ident: ident,
80
+
db: db,
81
+
l: logger,
82
+
wantedDids: make(map[string]struct{}),
83
84
// This will make the goroutine in StartJetstream wait until
85
+
// j.wantedDids has been populated, typically using addDids.
86
waitForDid: waitForDid,
87
}, nil
88
}
89
90
// StartJetstream starts the jetstream client and processes events using the provided processFunc.
91
+
// The caller is responsible for saving the last time_us to the database (just use your db.UpdateLastTimeUs).
92
func (j *JetstreamClient) StartJetstream(ctx context.Context, processFunc func(context.Context, *models.Event) error) error {
93
logger := j.l
94
95
+
sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc))
96
97
client, err := client.NewClient(j.cfg, log.New("jetstream"), sched)
98
if err != nil {
···
102
103
go func() {
104
if j.waitForDid {
105
+
for len(j.wantedDids) == 0 {
106
time.Sleep(time.Second)
107
}
108
}
109
logger.Info("done waiting for did")
110
+
111
+
go j.periodicLastTimeSave(ctx)
112
+
j.saveIfKilled(ctx)
113
+
114
j.connectAndRead(ctx)
115
}()
116
···
144
}
145
}
146
147
+
// save cursor periodically
148
+
func (j *JetstreamClient) periodicLastTimeSave(ctx context.Context) {
149
+
ticker := time.NewTicker(time.Minute)
150
+
defer ticker.Stop()
151
+
152
+
for {
153
+
select {
154
+
case <-ctx.Done():
155
+
return
156
+
case <-ticker.C:
157
+
j.db.SaveLastTimeUs(time.Now().UnixMicro())
158
+
}
159
+
}
160
+
}
161
+
162
func (j *JetstreamClient) getLastTimeUs(ctx context.Context) *int64 {
163
l := log.FromContext(ctx)
164
lastTimeUs, err := j.db.GetLastTimeUs()
···
171
}
172
}
173
174
+
// If last time is older than 2 days, start from now
175
if time.Now().UnixMicro()-lastTimeUs > 2*24*60*60*1000*1000 {
176
lastTimeUs = time.Now().UnixMicro()
177
l.Warn("last time us is older than 2 days; discarding that and starting from now")
178
+
err = j.db.SaveLastTimeUs(lastTimeUs)
179
if err != nil {
180
l.Error("failed to save last time us", "error", err)
181
}
···
184
l.Info("found last time_us", "time_us", lastTimeUs)
185
return &lastTimeUs
186
}
187
+
188
+
func (j *JetstreamClient) saveIfKilled(ctx context.Context) context.Context {
189
+
ctxWithCancel, cancel := context.WithCancel(ctx)
190
+
191
+
sigChan := make(chan os.Signal, 1)
192
+
193
+
signal.Notify(sigChan,
194
+
syscall.SIGINT,
195
+
syscall.SIGTERM,
196
+
syscall.SIGQUIT,
197
+
syscall.SIGHUP,
198
+
syscall.SIGKILL,
199
+
syscall.SIGSTOP,
200
+
)
201
+
202
+
go func() {
203
+
sig := <-sigChan
204
+
j.l.Info("Received signal, initiating graceful shutdown", "signal", sig)
205
+
206
+
lastTimeUs := time.Now().UnixMicro()
207
+
if err := j.db.SaveLastTimeUs(lastTimeUs); err != nil {
208
+
j.l.Error("Failed to save last time during shutdown", "error", err)
209
+
}
210
+
j.l.Info("Saved lastTimeUs before shutdown", "lastTimeUs", lastTimeUs)
211
+
212
+
j.cancelMu.Lock()
213
+
if j.cancel != nil {
214
+
j.cancel()
215
+
}
216
+
j.cancelMu.Unlock()
217
+
218
+
cancel()
219
+
220
+
os.Exit(0)
221
+
}()
222
+
223
+
return ctxWithCancel
224
+
}
+6
-10
knotserver/db/jetstream.go
+6
-10
knotserver/db/jetstream.go
···
1
package db
2
3
func (d *DB) SaveLastTimeUs(lastTimeUs int64) error {
4
-
_, err := d.db.Exec(`insert into _jetstream (last_time_us) values (?)`, lastTimeUs)
5
return err
6
}
7
8
-
func (d *DB) UpdateLastTimeUs(lastTimeUs int64) error {
9
-
_, err := d.db.Exec(`update _jetstream set last_time_us = ? where rowid = 1`, lastTimeUs)
10
-
if err != nil {
11
-
return err
12
-
}
13
-
return nil
14
-
}
15
-
16
func (d *DB) GetLastTimeUs() (int64, error) {
17
var lastTimeUs int64
18
-
row := d.db.QueryRow(`select last_time_us from _jetstream`)
19
err := row.Scan(&lastTimeUs)
20
return lastTimeUs, err
21
}
···
1
package db
2
3
func (d *DB) SaveLastTimeUs(lastTimeUs int64) error {
4
+
_, err := d.db.Exec(`
5
+
insert into _jetstream (id, last_time_us)
6
+
values (1, ?)
7
+
on conflict(id) do update set last_time_us = excluded.last_time_us
8
+
`, lastTimeUs)
9
return err
10
}
11
12
func (d *DB) GetLastTimeUs() (int64, error) {
13
var lastTimeUs int64
14
+
row := d.db.QueryRow(`select last_time_us from _jetstream where id = 1;`)
15
err := row.Scan(&lastTimeUs)
16
return lastTimeUs, err
17
}
+11
-11
knotserver/db/pubkeys.go
+11
-11
knotserver/db/pubkeys.go
···
23
Did: did,
24
}
25
pk.Key = record["key"]
26
-
pk.Created = record["created"]
27
28
return d.AddPublicKey(pk)
29
}
30
31
func (d *DB) AddPublicKey(pk PublicKey) error {
32
-
if pk.Created == "" {
33
-
pk.Created = time.Now().Format(time.RFC3339)
34
}
35
36
query := `insert or ignore into public_keys (did, key, created) values (?, ?, ?)`
37
-
_, err := d.db.Exec(query, pk.Did, pk.Key, pk.Created)
38
return err
39
}
40
···
44
return err
45
}
46
47
-
func (pk *PublicKey) JSON() map[string]interface{} {
48
-
return map[string]interface{}{
49
-
"did": pk.Did,
50
-
"key": pk.Key,
51
-
"created": pk.Created,
52
}
53
}
54
···
63
64
for rows.Next() {
65
var publicKey PublicKey
66
-
if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.Created); err != nil {
67
return nil, err
68
}
69
keys = append(keys, publicKey)
···
87
88
for rows.Next() {
89
var publicKey PublicKey
90
-
if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Created); err != nil {
91
return nil, err
92
}
93
keys = append(keys, publicKey)
···
23
Did: did,
24
}
25
pk.Key = record["key"]
26
+
pk.CreatedAt = record["createdAt"]
27
28
return d.AddPublicKey(pk)
29
}
30
31
func (d *DB) AddPublicKey(pk PublicKey) error {
32
+
if pk.CreatedAt == "" {
33
+
pk.CreatedAt = time.Now().Format(time.RFC3339)
34
}
35
36
query := `insert or ignore into public_keys (did, key, created) values (?, ?, ?)`
37
+
_, err := d.db.Exec(query, pk.Did, pk.Key, pk.CreatedAt)
38
return err
39
}
40
···
44
return err
45
}
46
47
+
func (pk *PublicKey) JSON() map[string]any {
48
+
return map[string]any{
49
+
"did": pk.Did,
50
+
"key": pk.Key,
51
+
"createdAt": pk.CreatedAt,
52
}
53
}
54
···
63
64
for rows.Next() {
65
var publicKey PublicKey
66
+
if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.CreatedAt); err != nil {
67
return nil, err
68
}
69
keys = append(keys, publicKey)
···
87
88
for rows.Next() {
89
var publicKey PublicKey
90
+
if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.CreatedAt); err != nil {
91
return nil, err
92
}
93
keys = append(keys, publicKey)
+112
-10
knotserver/git/diff.go
+112
-10
knotserver/git/diff.go
···
1
package git
2
3
import (
4
"fmt"
5
"log"
6
"strings"
7
8
"github.com/bluekeyes/go-gitdiff/gitdiff"
9
"github.com/go-git/go-git/v5/plumbing/object"
10
"tangled.sh/tangled.sh/core/types"
11
)
12
···
46
}
47
48
nd := types.NiceDiff{}
49
-
nd.Commit.This = c.Hash.String()
50
-
51
-
if parent.Hash.IsZero() {
52
-
nd.Commit.Parent = ""
53
-
} else {
54
-
nd.Commit.Parent = parent.Hash.String()
55
-
}
56
-
nd.Commit.Author = c.Author
57
-
nd.Commit.Message = c.Message
58
-
59
for _, d := range diffs {
60
ndiff := types.Diff{}
61
ndiff.Name.New = d.NewName
···
82
}
83
84
nd.Stat.FilesChanged = len(diffs)
85
86
return &nd, nil
87
}
···
1
package git
2
3
import (
4
+
"bytes"
5
"fmt"
6
"log"
7
+
"os"
8
+
"os/exec"
9
"strings"
10
11
"github.com/bluekeyes/go-gitdiff/gitdiff"
12
+
"github.com/go-git/go-git/v5/plumbing"
13
"github.com/go-git/go-git/v5/plumbing/object"
14
+
"tangled.sh/tangled.sh/core/patchutil"
15
"tangled.sh/tangled.sh/core/types"
16
)
17
···
51
}
52
53
nd := types.NiceDiff{}
54
for _, d := range diffs {
55
ndiff := types.Diff{}
56
ndiff.Name.New = d.NewName
···
77
}
78
79
nd.Stat.FilesChanged = len(diffs)
80
+
nd.Commit.This = c.Hash.String()
81
+
82
+
if parent.Hash.IsZero() {
83
+
nd.Commit.Parent = ""
84
+
} else {
85
+
nd.Commit.Parent = parent.Hash.String()
86
+
}
87
+
nd.Commit.Author = c.Author
88
+
nd.Commit.Message = c.Message
89
90
return &nd, nil
91
}
92
+
93
+
func (g *GitRepo) DiffTree(commit1, commit2 *object.Commit) (*types.DiffTree, error) {
94
+
tree1, err := commit1.Tree()
95
+
if err != nil {
96
+
return nil, err
97
+
}
98
+
99
+
tree2, err := commit2.Tree()
100
+
if err != nil {
101
+
return nil, err
102
+
}
103
+
104
+
diff, err := object.DiffTree(tree1, tree2)
105
+
if err != nil {
106
+
return nil, err
107
+
}
108
+
109
+
patch, err := diff.Patch()
110
+
if err != nil {
111
+
return nil, err
112
+
}
113
+
114
+
diffs, _, err := gitdiff.Parse(strings.NewReader(patch.String()))
115
+
if err != nil {
116
+
return nil, err
117
+
}
118
+
119
+
return &types.DiffTree{
120
+
Rev1: commit1.Hash.String(),
121
+
Rev2: commit2.Hash.String(),
122
+
Patch: patch.String(),
123
+
Diff: diffs,
124
+
}, nil
125
+
}
126
+
127
+
// FormatPatch generates a git-format-patch output between two commits,
128
+
// and returns the raw format-patch series, a parsed FormatPatch and an error.
129
+
func (g *GitRepo) FormatPatch(base, commit2 *object.Commit) (string, []patchutil.FormatPatch, error) {
130
+
var stdout bytes.Buffer
131
+
cmd := exec.Command(
132
+
"git",
133
+
"-C",
134
+
g.path,
135
+
"format-patch",
136
+
fmt.Sprintf("%s..%s", base.Hash.String(), commit2.Hash.String()),
137
+
"--stdout",
138
+
)
139
+
cmd.Stdout = &stdout
140
+
cmd.Stderr = os.Stderr
141
+
err := cmd.Run()
142
+
if err != nil {
143
+
return "", nil, err
144
+
}
145
+
146
+
formatPatch, err := patchutil.ExtractPatches(stdout.String())
147
+
if err != nil {
148
+
return "", nil, err
149
+
}
150
+
151
+
return stdout.String(), formatPatch, nil
152
+
}
153
+
154
+
func (g *GitRepo) MergeBase(commit1, commit2 *object.Commit) (*object.Commit, error) {
155
+
isAncestor, err := commit1.IsAncestor(commit2)
156
+
if err != nil {
157
+
return nil, err
158
+
}
159
+
160
+
if isAncestor {
161
+
return commit1, nil
162
+
}
163
+
164
+
mergeBase, err := commit1.MergeBase(commit2)
165
+
if err != nil {
166
+
return nil, err
167
+
}
168
+
169
+
if len(mergeBase) == 0 {
170
+
return nil, fmt.Errorf("failed to find a merge-base")
171
+
}
172
+
173
+
return mergeBase[0], nil
174
+
}
175
+
176
+
func (g *GitRepo) ResolveRevision(revStr string) (*object.Commit, error) {
177
+
rev, err := g.r.ResolveRevision(plumbing.Revision(revStr))
178
+
if err != nil {
179
+
return nil, fmt.Errorf("resolving revision %s: %w", revStr, err)
180
+
}
181
+
182
+
commit, err := g.r.CommitObject(*rev)
183
+
if err != nil {
184
+
185
+
return nil, fmt.Errorf("getting commit for %s: %w", revStr, err)
186
+
}
187
+
188
+
return commit, nil
189
+
}
+50
knotserver/git/fork.go
+50
knotserver/git/fork.go
···
···
1
+
package git
2
+
3
+
import (
4
+
"errors"
5
+
"fmt"
6
+
"os/exec"
7
+
8
+
"github.com/go-git/go-git/v5"
9
+
"github.com/go-git/go-git/v5/config"
10
+
)
11
+
12
+
func Fork(repoPath, source string) error {
13
+
_, err := git.PlainClone(repoPath, true, &git.CloneOptions{
14
+
URL: source,
15
+
SingleBranch: false,
16
+
})
17
+
18
+
if err != nil {
19
+
return fmt.Errorf("failed to bare clone repository: %w", err)
20
+
}
21
+
22
+
err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run()
23
+
if err != nil {
24
+
return fmt.Errorf("failed to configure hidden refs: %w", err)
25
+
}
26
+
27
+
return nil
28
+
}
29
+
30
+
// TrackHiddenRemoteRef tracks a hidden remote in the repository. For example,
31
+
// if the feature branch on the fork (forkRef) is feature-1, and the remoteRef,
32
+
// i.e. the branch we want to merge into, is main, this will result in a refspec:
33
+
//
34
+
// +refs/heads/main:refs/hidden/feature-1/main
35
+
func (g *GitRepo) TrackHiddenRemoteRef(forkRef, remoteRef string) error {
36
+
fetchOpts := &git.FetchOptions{
37
+
RefSpecs: []config.RefSpec{
38
+
config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/hidden/%s/%s", remoteRef, forkRef, remoteRef)),
39
+
},
40
+
RemoteName: "origin",
41
+
}
42
+
43
+
err := g.r.Fetch(fetchOpts)
44
+
if errors.Is(git.NoErrAlreadyUpToDate, err) {
45
+
return nil
46
+
} else if err != nil {
47
+
return fmt.Errorf("failed to fetch hidden remote: %s: %w", forkRef, err)
48
+
}
49
+
return nil
50
+
}
+83
-5
knotserver/git/git.go
+83
-5
knotserver/git/git.go
···
37
}
38
39
var (
40
-
ErrBinaryFile = fmt.Errorf("binary file")
41
)
42
43
type GitRepo struct {
···
131
return &g, nil
132
}
133
134
func (g *GitRepo) Commits() ([]*object.Commit, error) {
135
ci, err := g.r.Log(&git.LogOptions{From: g.h})
136
if err != nil {
···
144
})
145
146
return commits, nil
147
}
148
149
func (g *GitRepo) LastCommit() (*object.Commit, error) {
···
179
}
180
}
181
182
func (g *GitRepo) Tags() ([]*TagReference, error) {
183
iter, err := g.r.Tags()
184
if err != nil {
···
212
return tags, nil
213
}
214
215
-
func (g *GitRepo) Branches() ([]*plumbing.Reference, error) {
216
bi, err := g.r.Branches()
217
if err != nil {
218
return nil, fmt.Errorf("branchs: %w", err)
219
}
220
221
-
branches := []*plumbing.Reference{}
222
223
_ = bi.ForEach(func(ref *plumbing.Reference) error {
224
-
branches = append(branches, ref)
225
return nil
226
})
227
228
return branches, nil
229
}
230
231
func (g *GitRepo) FindMainBranch() (string, error) {
···
308
}
309
cacheMu.RUnlock()
310
311
-
cmd := exec.Command("git", "-C", g.path, "log", "-1", "--format=%H %ct", "--", path)
312
313
var out bytes.Buffer
314
cmd.Stdout = &out
···
37
}
38
39
var (
40
+
ErrBinaryFile = fmt.Errorf("binary file")
41
+
ErrNotBinaryFile = fmt.Errorf("not binary file")
42
)
43
44
type GitRepo struct {
···
132
return &g, nil
133
}
134
135
+
func PlainOpen(path string) (*GitRepo, error) {
136
+
var err error
137
+
g := GitRepo{path: path}
138
+
g.r, err = git.PlainOpen(path)
139
+
if err != nil {
140
+
return nil, fmt.Errorf("opening %s: %w", path, err)
141
+
}
142
+
return &g, nil
143
+
}
144
+
145
func (g *GitRepo) Commits() ([]*object.Commit, error) {
146
ci, err := g.r.Log(&git.LogOptions{From: g.h})
147
if err != nil {
···
155
})
156
157
return commits, nil
158
+
}
159
+
160
+
func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) {
161
+
return g.r.CommitObject(h)
162
}
163
164
func (g *GitRepo) LastCommit() (*object.Commit, error) {
···
194
}
195
}
196
197
+
func (g *GitRepo) RawContent(path string) ([]byte, error) {
198
+
c, err := g.r.CommitObject(g.h)
199
+
if err != nil {
200
+
return nil, fmt.Errorf("commit object: %w", err)
201
+
}
202
+
203
+
tree, err := c.Tree()
204
+
if err != nil {
205
+
return nil, fmt.Errorf("file tree: %w", err)
206
+
}
207
+
208
+
file, err := tree.File(path)
209
+
if err != nil {
210
+
return nil, err
211
+
}
212
+
213
+
reader, err := file.Reader()
214
+
if err != nil {
215
+
return nil, fmt.Errorf("opening file reader: %w", err)
216
+
}
217
+
defer reader.Close()
218
+
219
+
return io.ReadAll(reader)
220
+
}
221
+
222
func (g *GitRepo) Tags() ([]*TagReference, error) {
223
iter, err := g.r.Tags()
224
if err != nil {
···
252
return tags, nil
253
}
254
255
+
func (g *GitRepo) Branches() ([]types.Branch, error) {
256
bi, err := g.r.Branches()
257
if err != nil {
258
return nil, fmt.Errorf("branchs: %w", err)
259
}
260
261
+
branches := []types.Branch{}
262
+
263
+
defaultBranch, err := g.FindMainBranch()
264
+
if err != nil {
265
+
return nil, fmt.Errorf("getting default branch", "error", err.Error())
266
+
}
267
268
_ = bi.ForEach(func(ref *plumbing.Reference) error {
269
+
b := types.Branch{}
270
+
b.Hash = ref.Hash().String()
271
+
b.Name = ref.Name().Short()
272
+
273
+
// resolve commit that this branch points to
274
+
commit, _ := g.Commit(ref.Hash())
275
+
if commit != nil {
276
+
b.Commit = commit
277
+
}
278
+
279
+
if defaultBranch != "" && defaultBranch == b.Name {
280
+
b.IsDefault = true
281
+
}
282
+
283
+
branches = append(branches, b)
284
+
285
return nil
286
})
287
288
return branches, nil
289
+
}
290
+
291
+
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
292
+
ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false)
293
+
if err != nil {
294
+
return nil, fmt.Errorf("branch: %w", err)
295
+
}
296
+
297
+
if !ref.Name().IsBranch() {
298
+
return nil, fmt.Errorf("branch: %s is not a branch", ref.Name())
299
+
}
300
+
301
+
return ref, nil
302
+
}
303
+
304
+
func (g *GitRepo) SetDefaultBranch(branch string) error {
305
+
ref := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(branch))
306
+
return g.r.Storer.SetReference(ref)
307
}
308
309
func (g *GitRepo) FindMainBranch() (string, error) {
···
386
}
387
cacheMu.RUnlock()
388
389
+
cmd := exec.Command("git", "-C", g.path, "log", g.h.String(), "-1", "--format=%H %ct", "--", path)
390
391
var out bytes.Buffer
392
cmd.Stdout = &out
+17
-2
knotserver/git/merge.go
+17
-2
knotserver/git/merge.go
···
10
11
"github.com/go-git/go-git/v5"
12
"github.com/go-git/go-git/v5/plumbing"
13
)
14
15
type ErrMerge struct {
···
30
CommitBody string
31
AuthorName string
32
AuthorEmail string
33
}
34
35
func (e ErrMerge) Error() string {
···
89
if checkOnly {
90
cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
91
} else {
92
-
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
93
94
if opts != nil {
95
applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
96
applyCmd.Stderr = &stderr
···
153
}
154
155
func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
156
patchFile, err := g.createTempFileWithPatch(patchData)
157
if err != nil {
158
return &ErrMerge{
···
171
}
172
defer os.RemoveAll(tmpDir)
173
174
-
return g.applyPatch(tmpDir, patchFile, true, nil)
175
}
176
177
func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
···
10
11
"github.com/go-git/go-git/v5"
12
"github.com/go-git/go-git/v5/plumbing"
13
+
"tangled.sh/tangled.sh/core/patchutil"
14
)
15
16
type ErrMerge struct {
···
31
CommitBody string
32
AuthorName string
33
AuthorEmail string
34
+
FormatPatch bool
35
}
36
37
func (e ErrMerge) Error() string {
···
91
if checkOnly {
92
cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
93
} else {
94
+
// if patch is a format-patch, apply using 'git am'
95
+
if opts.FormatPatch {
96
+
amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile)
97
+
amCmd.Stderr = &stderr
98
+
if err := amCmd.Run(); err != nil {
99
+
return fmt.Errorf("patch application failed: %s", stderr.String())
100
+
}
101
+
return nil
102
+
}
103
104
+
// else, apply using 'git apply' and commit it manually
105
+
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
106
if opts != nil {
107
applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
108
applyCmd.Stderr = &stderr
···
165
}
166
167
func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
168
+
var opts MergeOptions
169
+
opts.FormatPatch = patchutil.IsFormatPatch(string(patchData))
170
+
171
patchFile, err := g.createTempFileWithPatch(patchData)
172
if err != nil {
173
return &ErrMerge{
···
186
}
187
defer os.RemoveAll(tmpDir)
188
189
+
return g.applyPatch(tmpDir, patchFile, true, &opts)
190
}
191
192
func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
+31
-24
knotserver/git/service/service.go
+31
-24
knotserver/git/service/service.go
···
8
"net/http"
9
"os/exec"
10
"strings"
11
"syscall"
12
)
13
···
68
}
69
70
func (c *ServiceCommand) UploadPack() error {
71
-
cmd := exec.Command("git", []string{
72
-
"-c", "uploadpack.allowFilter=true",
73
-
"upload-pack",
74
-
"--stateless-rpc",
75
-
".",
76
-
}...)
77
cmd.Dir = c.Dir
78
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
79
80
-
stdoutPipe, _ := cmd.StdoutPipe()
81
-
cmd.Stderr = cmd.Stdout
82
-
defer stdoutPipe.Close()
83
84
stdinPipe, err := cmd.StdinPipe()
85
if err != nil {
86
-
return err
87
}
88
-
defer stdinPipe.Close()
89
90
if err := cmd.Start(); err != nil {
91
-
log.Printf("git: failed to start git-upload-pack: %s", err)
92
-
return err
93
}
94
95
-
if _, err := io.Copy(stdinPipe, c.Stdin); err != nil {
96
-
log.Printf("git: failed to copy stdin: %s", err)
97
-
return err
98
-
}
99
-
stdinPipe.Close()
100
101
-
if _, err := io.Copy(newWriteFlusher(c.Stdout), stdoutPipe); err != nil {
102
-
log.Printf("git: failed to copy stdout: %s", err)
103
-
return err
104
-
}
105
if err := cmd.Wait(); err != nil {
106
-
log.Printf("git: failed to wait for git-upload-pack: %s", err)
107
-
return err
108
}
109
110
return nil
···
8
"net/http"
9
"os/exec"
10
"strings"
11
+
"sync"
12
"syscall"
13
)
14
···
69
}
70
71
func (c *ServiceCommand) UploadPack() error {
72
+
var stderr bytes.Buffer
73
+
74
+
cmd := exec.Command("git", "-c", "uploadpack.allowFilter=true",
75
+
"upload-pack", "--stateless-rpc", ".")
76
cmd.Dir = c.Dir
77
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
78
79
+
stdoutPipe, err := cmd.StdoutPipe()
80
+
if err != nil {
81
+
return fmt.Errorf("failed to create stdout pipe: %w", err)
82
+
}
83
+
84
+
cmd.Stderr = &stderr
85
86
stdinPipe, err := cmd.StdinPipe()
87
if err != nil {
88
+
return fmt.Errorf("failed to create stdin pipe: %w", err)
89
}
90
91
if err := cmd.Start(); err != nil {
92
+
return fmt.Errorf("failed to start git-upload-pack: %w", err)
93
}
94
95
+
var wg sync.WaitGroup
96
+
97
+
wg.Add(1)
98
+
go func() {
99
+
defer wg.Done()
100
+
defer stdinPipe.Close()
101
+
io.Copy(stdinPipe, c.Stdin)
102
+
}()
103
+
104
+
wg.Add(1)
105
+
go func() {
106
+
defer wg.Done()
107
+
io.Copy(newWriteFlusher(c.Stdout), stdoutPipe)
108
+
stdoutPipe.Close()
109
+
}()
110
+
111
+
wg.Wait()
112
113
if err := cmd.Wait(); err != nil {
114
+
return fmt.Errorf("git-upload-pack failed: %w, stderr: %s", err, stderr.String())
115
}
116
117
return nil
+8
-1
knotserver/git/tree.go
+8
-1
knotserver/git/tree.go
···
2
3
import (
4
"fmt"
5
+
"time"
6
7
"github.com/go-git/go-git/v5/plumbing/object"
8
"tangled.sh/tangled.sh/core/types"
···
57
lastCommit, err := g.LastCommitForPath(fpath)
58
if err != nil {
59
fmt.Println("error getting last commit time:", err)
60
+
// We don't want to skip the file, so worst case lets just
61
+
// populate it with "defaults".
62
+
lastCommit = &types.LastCommitInfo{
63
+
Hash: g.h,
64
+
Message: "",
65
+
When: time.Now(),
66
+
}
67
}
68
69
nts = append(nts, types.NiceTree{
+22
-17
knotserver/git.go
+22
-17
knotserver/git.go
···
34
func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) {
35
did := chi.URLParam(r, "did")
36
name := chi.URLParam(r, "name")
37
-
repo, _ := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
38
-
39
-
w.Header().Set("content-type", "application/x-git-upload-pack-result")
40
-
w.Header().Set("Connection", "Keep-Alive")
41
-
w.Header().Set("Transfer-Encoding", "chunked")
42
-
w.WriteHeader(http.StatusOK)
43
-
44
-
cmd := service.ServiceCommand{
45
-
Dir: repo,
46
-
Stdout: w,
47
}
48
49
-
var reader io.ReadCloser
50
-
reader = r.Body
51
-
52
if r.Header.Get("Content-Encoding") == "gzip" {
53
-
reader, err := gzip.NewReader(r.Body)
54
if err != nil {
55
writeError(w, err.Error(), 500)
56
d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
57
return
58
}
59
-
defer reader.Close()
60
}
61
62
-
cmd.Stdin = reader
63
if err := cmd.UploadPack(); err != nil {
64
-
writeError(w, err.Error(), 500)
65
d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
66
return
67
}
···
34
func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) {
35
did := chi.URLParam(r, "did")
36
name := chi.URLParam(r, "name")
37
+
repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
38
+
if err != nil {
39
+
writeError(w, err.Error(), 500)
40
+
d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
41
+
return
42
}
43
44
+
var bodyReader io.ReadCloser = r.Body
45
if r.Header.Get("Content-Encoding") == "gzip" {
46
+
gzipReader, err := gzip.NewReader(r.Body)
47
if err != nil {
48
writeError(w, err.Error(), 500)
49
d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
50
return
51
}
52
+
defer gzipReader.Close()
53
+
bodyReader = gzipReader
54
+
}
55
+
56
+
w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
57
+
w.Header().Set("Connection", "Keep-Alive")
58
+
59
+
d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
60
+
61
+
cmd := service.ServiceCommand{
62
+
Dir: repo,
63
+
Stdout: w,
64
+
Stdin: bodyReader,
65
}
66
67
+
w.WriteHeader(http.StatusOK)
68
+
69
if err := cmd.UploadPack(); err != nil {
70
d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
71
return
72
}
+51
-2
knotserver/handler.go
+51
-2
knotserver/handler.go
···
5
"fmt"
6
"log/slog"
7
"net/http"
8
9
"github.com/go-chi/chi/v5"
10
"tangled.sh/tangled.sh/core/jetstream"
···
59
if err != nil {
60
return nil, fmt.Errorf("failed to get all Dids: %w", err)
61
}
62
if len(dids) > 0 {
63
h.knotInitialized = true
64
close(h.init)
65
-
// h.jc.UpdateDids(dids)
66
}
67
68
r.Get("/", h.Index)
69
r.Route("/{did}", func(r chi.Router) {
70
// Repo routes
71
r.Route("/{name}", func(r chi.Router) {
···
77
r.Get("/", h.RepoIndex)
78
r.Get("/info/refs", h.InfoRefs)
79
r.Post("/git-upload-pack", h.UploadPack)
80
81
r.Route("/merge", func(r chi.Router) {
82
r.With(h.VerifySignature)
···
93
r.Get("/*", h.Blob)
94
})
95
96
r.Get("/log/{ref}", h.Log)
97
r.Get("/archive/{file}", h.Archive)
98
r.Get("/commit/{ref}", h.Diff)
99
r.Get("/tags", h.Tags)
100
-
r.Get("/branches", h.Branches)
101
})
102
})
103
···
106
r.Use(h.VerifySignature)
107
r.Put("/new", h.NewRepo)
108
r.Delete("/", h.RemoveRepo)
109
})
110
111
r.Route("/member", func(r chi.Router) {
···
124
125
return r, nil
126
}
···
5
"fmt"
6
"log/slog"
7
"net/http"
8
+
"runtime/debug"
9
10
"github.com/go-chi/chi/v5"
11
"tangled.sh/tangled.sh/core/jetstream"
···
60
if err != nil {
61
return nil, fmt.Errorf("failed to get all Dids: %w", err)
62
}
63
+
64
if len(dids) > 0 {
65
h.knotInitialized = true
66
close(h.init)
67
+
for _, d := range dids {
68
+
h.jc.AddDid(d)
69
+
}
70
}
71
72
r.Get("/", h.Index)
73
+
r.Get("/capabilities", h.Capabilities)
74
+
r.Get("/version", h.Version)
75
r.Route("/{did}", func(r chi.Router) {
76
// Repo routes
77
r.Route("/{name}", func(r chi.Router) {
···
83
r.Get("/", h.RepoIndex)
84
r.Get("/info/refs", h.InfoRefs)
85
r.Post("/git-upload-pack", h.UploadPack)
86
+
r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects
87
+
88
+
r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef)
89
90
r.Route("/merge", func(r chi.Router) {
91
r.With(h.VerifySignature)
···
102
r.Get("/*", h.Blob)
103
})
104
105
+
r.Route("/raw/{ref}", func(r chi.Router) {
106
+
r.Get("/*", h.BlobRaw)
107
+
})
108
+
109
r.Get("/log/{ref}", h.Log)
110
r.Get("/archive/{file}", h.Archive)
111
r.Get("/commit/{ref}", h.Diff)
112
r.Get("/tags", h.Tags)
113
+
r.Route("/branches", func(r chi.Router) {
114
+
r.Get("/", h.Branches)
115
+
r.Get("/{branch}", h.Branch)
116
+
r.Route("/default", func(r chi.Router) {
117
+
r.Get("/", h.DefaultBranch)
118
+
r.With(h.VerifySignature).Put("/", h.SetDefaultBranch)
119
+
})
120
+
})
121
})
122
})
123
···
126
r.Use(h.VerifySignature)
127
r.Put("/new", h.NewRepo)
128
r.Delete("/", h.RemoveRepo)
129
+
r.Post("/fork", h.RepoFork)
130
})
131
132
r.Route("/member", func(r chi.Router) {
···
145
146
return r, nil
147
}
148
+
149
+
// version is set during build time.
150
+
var version string
151
+
152
+
func (h *Handle) Version(w http.ResponseWriter, r *http.Request) {
153
+
if version == "" {
154
+
info, ok := debug.ReadBuildInfo()
155
+
if !ok {
156
+
http.Error(w, "failed to read build info", http.StatusInternalServerError)
157
+
return
158
+
}
159
+
160
+
var modVer string
161
+
for _, mod := range info.Deps {
162
+
if mod.Path == "tangled.sh/tangled.sh/knotserver" {
163
+
version = mod.Version
164
+
break
165
+
}
166
+
}
167
+
168
+
if modVer == "" {
169
+
version = "unknown"
170
+
}
171
+
}
172
+
173
+
w.Header().Set("Content-Type", "text/plain")
174
+
fmt.Fprintf(w, "knotserver/%s", version)
175
+
}
+4
-4
knotserver/jetstream.go
+4
-4
knotserver/jetstream.go
···
43
return fmt.Errorf("failed to enforce permissions: %w", err)
44
}
45
46
-
if err := h.e.AddMember(ThisServer, record.Member); err != nil {
47
l.Error("failed to add member", "error", err)
48
return fmt.Errorf("failed to add member: %w", err)
49
}
50
-
l.Info("added member from firehose", "member", record.Member)
51
52
if err := h.db.AddDid(did); err != nil {
53
l.Error("failed to add did", "error", err)
54
return fmt.Errorf("failed to add did: %w", err)
55
}
56
57
if err := h.fetchAndAddKeys(ctx, did); err != nil {
58
return fmt.Errorf("failed to fetch and add keys: %w", err)
···
115
eventTime := event.TimeUS
116
lastTimeUs := eventTime + 1
117
fmt.Println("lastTimeUs", lastTimeUs)
118
-
if err := h.db.UpdateLastTimeUs(lastTimeUs); err != nil {
119
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
120
}
121
-
// h.jc.UpdateDids([]string{did})
122
}()
123
124
raw := json.RawMessage(event.Commit.Record)
···
43
return fmt.Errorf("failed to enforce permissions: %w", err)
44
}
45
46
+
if err := h.e.AddMember(ThisServer, record.Subject); err != nil {
47
l.Error("failed to add member", "error", err)
48
return fmt.Errorf("failed to add member: %w", err)
49
}
50
+
l.Info("added member from firehose", "member", record.Subject)
51
52
if err := h.db.AddDid(did); err != nil {
53
l.Error("failed to add did", "error", err)
54
return fmt.Errorf("failed to add did: %w", err)
55
}
56
+
h.jc.AddDid(did)
57
58
if err := h.fetchAndAddKeys(ctx, did); err != nil {
59
return fmt.Errorf("failed to fetch and add keys: %w", err)
···
116
eventTime := event.TimeUS
117
lastTimeUs := eventTime + 1
118
fmt.Println("lastTimeUs", lastTimeUs)
119
+
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
120
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
121
}
122
}()
123
124
raw := json.RawMessage(event.Commit.Record)
+343
-21
knotserver/routes.go
+343
-21
knotserver/routes.go
···
24
"github.com/go-git/go-git/v5/plumbing/object"
25
"tangled.sh/tangled.sh/core/knotserver/db"
26
"tangled.sh/tangled.sh/core/knotserver/git"
27
"tangled.sh/tangled.sh/core/types"
28
)
29
30
func (h *Handle) Index(w http.ResponseWriter, r *http.Request) {
31
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
32
}
33
34
func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) {
···
71
return
72
}
73
74
-
bs := []types.Branch{}
75
-
for _, branch := range branches {
76
-
b := types.Branch{}
77
-
b.Hash = branch.Hash().String()
78
-
b.Name = branch.Name().Short()
79
-
bs = append(bs, b)
80
-
}
81
-
82
tags, err := gr.Tags()
83
if err != nil {
84
// Non-fatal, we *should* have at least one branch to show.
···
138
Readme: readmeContent,
139
ReadmeFileName: readmeFile,
140
Files: files,
141
-
Branches: bs,
142
Tags: rtags,
143
TotalCommits: total,
144
}
···
180
return
181
}
182
183
func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) {
184
treePath := chi.URLParam(r, "*")
185
ref := chi.URLParam(r, "ref")
186
ref, _ = url.PathUnescape(ref)
187
188
-
l := h.l.With("handler", "FileContent", "ref", ref, "treePath", treePath)
189
190
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
191
gr, err := git.Open(path, ref)
···
271
272
func (h *Handle) Log(w http.ResponseWriter, r *http.Request) {
273
ref := chi.URLParam(r, "ref")
274
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
275
276
l := h.l.With("handler", "Log", "ref", ref, "path", path)
···
420
return
421
}
422
423
-
bs := []types.Branch{}
424
-
for _, branch := range branches {
425
-
b := types.Branch{}
426
-
b.Hash = branch.Hash().String()
427
-
b.Name = branch.Name().Short()
428
-
bs = append(bs, b)
429
}
430
431
-
resp := types.RepoBranchesResponse{
432
-
Branches: bs,
433
}
434
435
writeJSON(w, resp)
···
448
return
449
}
450
451
-
data := make([]map[string]interface{}, 0)
452
for _, key := range keys {
453
j := key.JSON()
454
data = append(data, j)
···
501
name := data.Name
502
defaultBranch := data.DefaultBranch
503
504
relativeRepoPath := filepath.Join(did, name)
505
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
506
err := git.InitBare(repoPath, defaultBranch)
···
526
w.WriteHeader(http.StatusNoContent)
527
}
528
529
func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
530
l := h.l.With("handler", "RemoveRepo")
531
···
585
notFound(w)
586
return
587
}
588
if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil {
589
var mergeErr *git.ErrMerge
590
if errors.As(err, &mergeErr) {
···
665
h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error())
666
}
667
668
func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) {
669
l := h.l.With("handler", "AddMember")
670
···
684
writeError(w, err.Error(), http.StatusInternalServerError)
685
return
686
}
687
-
688
h.jc.AddDid(did)
689
if err := h.e.AddMember(ThisServer, did); err != nil {
690
l.Error("adding member", "error", err.Error())
691
writeError(w, err.Error(), http.StatusInternalServerError)
···
739
w.WriteHeader(http.StatusNoContent)
740
}
741
742
func (h *Handle) Init(w http.ResponseWriter, r *http.Request) {
743
l := h.l.With("handler", "Init")
744
···
768
writeError(w, err.Error(), http.StatusInternalServerError)
769
return
770
}
771
772
-
// h.jc.UpdateDids([]string{data.Did})
773
if err := h.e.AddOwner(ThisServer, data.Did); err != nil {
774
l.Error("adding owner", "error", err.Error())
775
writeError(w, err.Error(), http.StatusInternalServerError)
···
794
func (h *Handle) Health(w http.ResponseWriter, r *http.Request) {
795
w.Write([]byte("ok"))
796
}
···
24
"github.com/go-git/go-git/v5/plumbing/object"
25
"tangled.sh/tangled.sh/core/knotserver/db"
26
"tangled.sh/tangled.sh/core/knotserver/git"
27
+
"tangled.sh/tangled.sh/core/patchutil"
28
"tangled.sh/tangled.sh/core/types"
29
)
30
31
func (h *Handle) Index(w http.ResponseWriter, r *http.Request) {
32
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
33
+
}
34
+
35
+
func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) {
36
+
w.Header().Set("Content-Type", "application/json")
37
+
38
+
capabilities := map[string]any{
39
+
"pull_requests": map[string]any{
40
+
"format_patch": true,
41
+
"patch_submissions": true,
42
+
"branch_submissions": true,
43
+
"fork_submissions": true,
44
+
},
45
+
}
46
+
47
+
jsonData, err := json.Marshal(capabilities)
48
+
if err != nil {
49
+
http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError)
50
+
return
51
+
}
52
+
53
+
w.Write(jsonData)
54
}
55
56
func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) {
···
93
return
94
}
95
96
tags, err := gr.Tags()
97
if err != nil {
98
// Non-fatal, we *should* have at least one branch to show.
···
152
Readme: readmeContent,
153
ReadmeFileName: readmeFile,
154
Files: files,
155
+
Branches: branches,
156
Tags: rtags,
157
TotalCommits: total,
158
}
···
194
return
195
}
196
197
+
func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) {
198
+
treePath := chi.URLParam(r, "*")
199
+
ref := chi.URLParam(r, "ref")
200
+
ref, _ = url.PathUnescape(ref)
201
+
202
+
l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath)
203
+
204
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
205
+
gr, err := git.Open(path, ref)
206
+
if err != nil {
207
+
notFound(w)
208
+
return
209
+
}
210
+
211
+
contents, err := gr.RawContent(treePath)
212
+
if err != nil {
213
+
writeError(w, err.Error(), http.StatusBadRequest)
214
+
l.Error("file content", "error", err.Error())
215
+
return
216
+
}
217
+
218
+
mimeType := http.DetectContentType(contents)
219
+
220
+
// exception for svg
221
+
if strings.HasPrefix(mimeType, "text/xml") && filepath.Ext(treePath) == ".svg" {
222
+
mimeType = "image/svg+xml"
223
+
}
224
+
225
+
if !strings.HasPrefix(mimeType, "image/") && !strings.HasPrefix(mimeType, "video/") {
226
+
l.Error("attempted to serve non-image/video file", "mimetype", mimeType)
227
+
writeError(w, "only image and video files can be accessed directly", http.StatusForbidden)
228
+
return
229
+
}
230
+
231
+
w.Header().Set("Cache-Control", "public, max-age=86400") // cache for 24 hours
232
+
w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents)))
233
+
w.Header().Set("Content-Type", mimeType)
234
+
w.Write(contents)
235
+
}
236
+
237
func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) {
238
treePath := chi.URLParam(r, "*")
239
ref := chi.URLParam(r, "ref")
240
ref, _ = url.PathUnescape(ref)
241
242
+
l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath)
243
244
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
245
gr, err := git.Open(path, ref)
···
325
326
func (h *Handle) Log(w http.ResponseWriter, r *http.Request) {
327
ref := chi.URLParam(r, "ref")
328
+
ref, _ = url.PathUnescape(ref)
329
+
330
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
331
332
l := h.l.With("handler", "Log", "ref", ref, "path", path)
···
476
return
477
}
478
479
+
resp := types.RepoBranchesResponse{
480
+
Branches: branches,
481
}
482
483
+
writeJSON(w, resp)
484
+
return
485
+
}
486
+
487
+
func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) {
488
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
489
+
branchName := chi.URLParam(r, "branch")
490
+
branchName, _ = url.PathUnescape(branchName)
491
+
492
+
l := h.l.With("handler", "Branch")
493
+
494
+
gr, err := git.PlainOpen(path)
495
+
if err != nil {
496
+
notFound(w)
497
+
return
498
+
}
499
+
500
+
ref, err := gr.Branch(branchName)
501
+
if err != nil {
502
+
l.Error("getting branch", "error", err.Error())
503
+
writeError(w, err.Error(), http.StatusInternalServerError)
504
+
return
505
+
}
506
+
507
+
commit, err := gr.Commit(ref.Hash())
508
+
if err != nil {
509
+
l.Error("getting commit object", "error", err.Error())
510
+
writeError(w, err.Error(), http.StatusInternalServerError)
511
+
return
512
+
}
513
+
514
+
defaultBranch, err := gr.FindMainBranch()
515
+
isDefault := false
516
+
if err != nil {
517
+
l.Error("getting default branch", "error", err.Error())
518
+
// do not quit though
519
+
} else if defaultBranch == branchName {
520
+
isDefault = true
521
+
}
522
+
523
+
resp := types.RepoBranchResponse{
524
+
Branch: types.Branch{
525
+
Reference: types.Reference{
526
+
Name: ref.Name().Short(),
527
+
Hash: ref.Hash().String(),
528
+
},
529
+
Commit: commit,
530
+
IsDefault: isDefault,
531
+
},
532
}
533
534
writeJSON(w, resp)
···
547
return
548
}
549
550
+
data := make([]map[string]any, 0)
551
for _, key := range keys {
552
j := key.JSON()
553
data = append(data, j)
···
600
name := data.Name
601
defaultBranch := data.DefaultBranch
602
603
+
if err := validateRepoName(name); err != nil {
604
+
l.Error("creating repo", "error", err.Error())
605
+
writeError(w, err.Error(), http.StatusBadRequest)
606
+
return
607
+
}
608
+
609
relativeRepoPath := filepath.Join(did, name)
610
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
611
err := git.InitBare(repoPath, defaultBranch)
···
631
w.WriteHeader(http.StatusNoContent)
632
}
633
634
+
func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) {
635
+
l := h.l.With("handler", "RepoFork")
636
+
637
+
data := struct {
638
+
Did string `json:"did"`
639
+
Source string `json:"source"`
640
+
Name string `json:"name,omitempty"`
641
+
}{}
642
+
643
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
644
+
writeError(w, "invalid request body", http.StatusBadRequest)
645
+
return
646
+
}
647
+
648
+
did := data.Did
649
+
source := data.Source
650
+
651
+
if did == "" || source == "" {
652
+
l.Error("invalid request body, empty did or name")
653
+
w.WriteHeader(http.StatusBadRequest)
654
+
return
655
+
}
656
+
657
+
var name string
658
+
if data.Name != "" {
659
+
name = data.Name
660
+
} else {
661
+
name = filepath.Base(source)
662
+
}
663
+
664
+
relativeRepoPath := filepath.Join(did, name)
665
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
666
+
667
+
err := git.Fork(repoPath, source)
668
+
if err != nil {
669
+
l.Error("forking repo", "error", err.Error())
670
+
writeError(w, err.Error(), http.StatusInternalServerError)
671
+
return
672
+
}
673
+
674
+
// add perms for this user to access the repo
675
+
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
676
+
if err != nil {
677
+
l.Error("adding repo permissions", "error", err.Error())
678
+
writeError(w, err.Error(), http.StatusInternalServerError)
679
+
return
680
+
}
681
+
682
+
w.WriteHeader(http.StatusNoContent)
683
+
}
684
+
685
func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
686
l := h.l.With("handler", "RemoveRepo")
687
···
741
notFound(w)
742
return
743
}
744
+
745
+
mo.FormatPatch = patchutil.IsFormatPatch(patch)
746
+
747
if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil {
748
var mergeErr *git.ErrMerge
749
if errors.As(err, &mergeErr) {
···
824
h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error())
825
}
826
827
+
func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) {
828
+
rev1 := chi.URLParam(r, "rev1")
829
+
rev1, _ = url.PathUnescape(rev1)
830
+
831
+
rev2 := chi.URLParam(r, "rev2")
832
+
rev2, _ = url.PathUnescape(rev2)
833
+
834
+
l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2)
835
+
836
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
837
+
gr, err := git.PlainOpen(path)
838
+
if err != nil {
839
+
notFound(w)
840
+
return
841
+
}
842
+
843
+
commit1, err := gr.ResolveRevision(rev1)
844
+
if err != nil {
845
+
l.Error("error resolving revision 1", "msg", err.Error())
846
+
writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest)
847
+
return
848
+
}
849
+
850
+
commit2, err := gr.ResolveRevision(rev2)
851
+
if err != nil {
852
+
l.Error("error resolving revision 2", "msg", err.Error())
853
+
writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest)
854
+
return
855
+
}
856
+
857
+
mergeBase, err := gr.MergeBase(commit1, commit2)
858
+
if err != nil {
859
+
l.Error("failed to find merge-base", "msg", err.Error())
860
+
writeError(w, "failed to calculate diff", http.StatusBadRequest)
861
+
return
862
+
}
863
+
864
+
rawPatch, formatPatch, err := gr.FormatPatch(mergeBase, commit2)
865
+
if err != nil {
866
+
l.Error("error comparing revisions", "msg", err.Error())
867
+
writeError(w, "error comparing revisions", http.StatusBadRequest)
868
+
return
869
+
}
870
+
871
+
writeJSON(w, types.RepoFormatPatchResponse{
872
+
Rev1: commit1.Hash.String(),
873
+
Rev2: commit2.Hash.String(),
874
+
FormatPatch: formatPatch,
875
+
Patch: rawPatch,
876
+
})
877
+
return
878
+
}
879
+
880
+
func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) {
881
+
l := h.l.With("handler", "NewHiddenRef")
882
+
883
+
forkRef := chi.URLParam(r, "forkRef")
884
+
forkRef, _ = url.PathUnescape(forkRef)
885
+
886
+
remoteRef := chi.URLParam(r, "remoteRef")
887
+
remoteRef, _ = url.PathUnescape(remoteRef)
888
+
889
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
890
+
gr, err := git.PlainOpen(path)
891
+
if err != nil {
892
+
notFound(w)
893
+
return
894
+
}
895
+
896
+
err = gr.TrackHiddenRemoteRef(forkRef, remoteRef)
897
+
if err != nil {
898
+
l.Error("error tracking hidden remote ref", "msg", err.Error())
899
+
writeError(w, "error tracking hidden remote ref", http.StatusBadRequest)
900
+
return
901
+
}
902
+
903
+
w.WriteHeader(http.StatusNoContent)
904
+
return
905
+
}
906
+
907
func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) {
908
l := h.l.With("handler", "AddMember")
909
···
923
writeError(w, err.Error(), http.StatusInternalServerError)
924
return
925
}
926
h.jc.AddDid(did)
927
+
928
if err := h.e.AddMember(ThisServer, did); err != nil {
929
l.Error("adding member", "error", err.Error())
930
writeError(w, err.Error(), http.StatusInternalServerError)
···
978
w.WriteHeader(http.StatusNoContent)
979
}
980
981
+
func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) {
982
+
l := h.l.With("handler", "DefaultBranch")
983
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
984
+
985
+
gr, err := git.Open(path, "")
986
+
if err != nil {
987
+
notFound(w)
988
+
return
989
+
}
990
+
991
+
branch, err := gr.FindMainBranch()
992
+
if err != nil {
993
+
writeError(w, err.Error(), http.StatusInternalServerError)
994
+
l.Error("getting default branch", "error", err.Error())
995
+
return
996
+
}
997
+
998
+
writeJSON(w, types.RepoDefaultBranchResponse{
999
+
Branch: branch,
1000
+
})
1001
+
}
1002
+
1003
+
func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1004
+
l := h.l.With("handler", "SetDefaultBranch")
1005
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1006
+
1007
+
data := struct {
1008
+
Branch string `json:"branch"`
1009
+
}{}
1010
+
1011
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1012
+
writeError(w, err.Error(), http.StatusBadRequest)
1013
+
return
1014
+
}
1015
+
1016
+
gr, err := git.Open(path, "")
1017
+
if err != nil {
1018
+
notFound(w)
1019
+
return
1020
+
}
1021
+
1022
+
err = gr.SetDefaultBranch(data.Branch)
1023
+
if err != nil {
1024
+
writeError(w, err.Error(), http.StatusInternalServerError)
1025
+
l.Error("setting default branch", "error", err.Error())
1026
+
return
1027
+
}
1028
+
1029
+
w.WriteHeader(http.StatusNoContent)
1030
+
}
1031
+
1032
func (h *Handle) Init(w http.ResponseWriter, r *http.Request) {
1033
l := h.l.With("handler", "Init")
1034
···
1058
writeError(w, err.Error(), http.StatusInternalServerError)
1059
return
1060
}
1061
+
h.jc.AddDid(data.Did)
1062
1063
if err := h.e.AddOwner(ThisServer, data.Did); err != nil {
1064
l.Error("adding owner", "error", err.Error())
1065
writeError(w, err.Error(), http.StatusInternalServerError)
···
1084
func (h *Handle) Health(w http.ResponseWriter, r *http.Request) {
1085
w.Write([]byte("ok"))
1086
}
1087
+
1088
+
func validateRepoName(name string) error {
1089
+
// check for path traversal attempts
1090
+
if name == "." || name == ".." ||
1091
+
strings.Contains(name, "/") || strings.Contains(name, "\\") {
1092
+
return fmt.Errorf("Repository name contains invalid path characters")
1093
+
}
1094
+
1095
+
// check for sequences that could be used for traversal when normalized
1096
+
if strings.Contains(name, "./") || strings.Contains(name, "../") ||
1097
+
strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
1098
+
return fmt.Errorf("Repository name contains invalid path sequence")
1099
+
}
1100
+
1101
+
// then continue with character validation
1102
+
for _, char := range name {
1103
+
if !((char >= 'a' && char <= 'z') ||
1104
+
(char >= 'A' && char <= 'Z') ||
1105
+
(char >= '0' && char <= '9') ||
1106
+
char == '-' || char == '_' || char == '.') {
1107
+
return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
1108
+
}
1109
+
}
1110
+
1111
+
// additional check to prevent multiple sequential dots
1112
+
if strings.Contains(name, "..") {
1113
+
return fmt.Errorf("Repository name cannot contain sequential dots")
1114
+
}
1115
+
1116
+
// if all checks pass
1117
+
return nil
1118
+
}
+72
lexicons/actor/profile.json
+72
lexicons/actor/profile.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.actor.profile",
4
+
"defs": {
5
+
"main": {
6
+
"type": "record",
7
+
"description": "A declaration of a Tangled account profile.",
8
+
"key": "literal:self",
9
+
"record": {
10
+
"type": "object",
11
+
"required": [
12
+
"bluesky"
13
+
],
14
+
"properties": {
15
+
"description": {
16
+
"type": "string",
17
+
"description": "Free-form profile description text.",
18
+
"maxGraphemes": 256,
19
+
"maxLength": 2560
20
+
},
21
+
"links": {
22
+
"type": "array",
23
+
"minLength": 0,
24
+
"maxLength": 5,
25
+
"items": {
26
+
"type": "string",
27
+
"description": "Any URI, intended for social profiles or websites, can be used to link DIDs/AT-URIs too.",
28
+
"format": "uri"
29
+
}
30
+
},
31
+
"stats": {
32
+
"type": "array",
33
+
"minLength": 0,
34
+
"maxLength": 2,
35
+
"items": {
36
+
"type": "string",
37
+
"description": "Vanity stats.",
38
+
"enum": [
39
+
"merged-pull-request-count",
40
+
"closed-pull-request-count",
41
+
"open-pull-request-count",
42
+
"open-issue-count",
43
+
"closed-issue-count",
44
+
"repository-count"
45
+
]
46
+
}
47
+
},
48
+
"bluesky": {
49
+
"type": "boolean",
50
+
"description": "Include link to this account on Bluesky."
51
+
},
52
+
"location": {
53
+
"type": "string",
54
+
"description": "Free-form location text.",
55
+
"maxGraphemes": 40,
56
+
"maxLength": 400
57
+
},
58
+
"pinnedRepositories": {
59
+
"type": "array",
60
+
"description": "Any ATURI, it is up to appviews to validate these fields.",
61
+
"minLength": 0,
62
+
"maxLength": 6,
63
+
"items": {
64
+
"type": "string",
65
+
"format": "at-uri"
66
+
}
67
+
}
68
+
}
69
+
}
70
+
}
71
+
}
72
+
}
+52
lexicons/artifact.json
+52
lexicons/artifact.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.artifact",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"name",
14
+
"repo",
15
+
"tag",
16
+
"createdAt",
17
+
"artifact"
18
+
],
19
+
"properties": {
20
+
"name": {
21
+
"type": "string",
22
+
"description": "name of the artifact"
23
+
},
24
+
"repo": {
25
+
"type": "string",
26
+
"format": "at-uri",
27
+
"description": "repo that this artifact is being uploaded to"
28
+
},
29
+
"tag": {
30
+
"type": "bytes",
31
+
"description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)",
32
+
"minLength": 20,
33
+
"maxLength": 20
34
+
},
35
+
"createdAt": {
36
+
"type": "string",
37
+
"format": "datetime",
38
+
"description": "time of creation of this artifact"
39
+
},
40
+
"artifact": {
41
+
"type": "blob",
42
+
"description": "the artifact",
43
+
"accept": [
44
+
"*/*"
45
+
],
46
+
"maxSize": 52428800
47
+
}
48
+
}
49
+
}
50
+
}
51
+
}
52
+
}
+29
lexicons/feed/star.json
+29
lexicons/feed/star.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.feed.star",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"subject",
14
+
"createdAt"
15
+
],
16
+
"properties": {
17
+
"subject": {
18
+
"type": "string",
19
+
"format": "at-uri"
20
+
},
21
+
"createdAt": {
22
+
"type": "string",
23
+
"format": "datetime"
24
+
}
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
-30
lexicons/follow.json
-30
lexicons/follow.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.graph.follow",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"createdAt",
14
-
"subject"
15
-
],
16
-
"properties": {
17
-
"createdAt": {
18
-
"type": "string",
19
-
"format": "datetime"
20
-
},
21
-
"subject": {
22
-
"type": "string",
23
-
"format": "did"
24
-
}
25
-
}
26
-
}
27
-
}
28
-
}
29
-
}
30
-
···
+29
lexicons/graph/follow.json
+29
lexicons/graph/follow.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.graph.follow",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"subject",
14
+
"createdAt"
15
+
],
16
+
"properties": {
17
+
"subject": {
18
+
"type": "string",
19
+
"format": "did"
20
+
},
21
+
"createdAt": {
22
+
"type": "string",
23
+
"format": "datetime"
24
+
}
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
+5
-1
lexicons/issue/comment.json
+5
-1
lexicons/issue/comment.json
+7
-1
lexicons/issue/issue.json
+7
-1
lexicons/issue/issue.json
+4
-1
lexicons/issue/state.json
+4
-1
lexicons/issue/state.json
+34
lexicons/knot/member.json
+34
lexicons/knot/member.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.knot.member",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"subject",
14
+
"domain",
15
+
"createdAt"
16
+
],
17
+
"properties": {
18
+
"subject": {
19
+
"type": "string",
20
+
"format": "did"
21
+
},
22
+
"domain": {
23
+
"type": "string",
24
+
"description": "domain that this member now belongs to"
25
+
},
26
+
"createdAt": {
27
+
"type": "string",
28
+
"format": "datetime"
29
+
}
30
+
}
31
+
}
32
+
}
33
+
}
34
+
}
-33
lexicons/member.json
-33
lexicons/member.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.knot.member",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"member",
14
-
"domain"
15
-
],
16
-
"properties": {
17
-
"member": {
18
-
"type": "string",
19
-
"format": "did"
20
-
},
21
-
"domain": {
22
-
"type": "string",
23
-
"description": "domain that this member now belongs to"
24
-
},
25
-
"addedAt": {
26
-
"type": "string",
27
-
"format": "datetime"
28
-
}
29
-
}
30
-
}
31
-
}
32
-
}
33
-
}
···
+2
-4
lexicons/publicKey.json
+2
-4
lexicons/publicKey.json
···
12
"required": [
13
"key",
14
"name",
15
-
"created"
16
],
17
"properties": {
18
"key": {
19
"type": "string",
20
"maxLength": 4096,
21
-
"maxGraphemes": 4096,
22
"description": "public key contents"
23
},
24
"name": {
25
"type": "string",
26
-
"format": "string",
27
"description": "human-readable name for this key"
28
},
29
-
"created": {
30
"type": "string",
31
"format": "datetime",
32
"description": "key upload timestamp"
···
12
"required": [
13
"key",
14
"name",
15
+
"createdAt"
16
],
17
"properties": {
18
"key": {
19
"type": "string",
20
"maxLength": 4096,
21
"description": "public key contents"
22
},
23
"name": {
24
"type": "string",
25
"description": "human-readable name for this key"
26
},
27
+
"createdAt": {
28
"type": "string",
29
"format": "datetime",
30
"description": "key upload timestamp"
+5
-1
lexicons/pulls/comment.json
+5
-1
lexicons/pulls/comment.json
+30
-8
lexicons/pulls/pull.json
+30
-8
lexicons/pulls/pull.json
···
9
"key": "tid",
10
"record": {
11
"type": "object",
12
-
"required": ["targetRepo", "targetBranch", "pullId", "title", "patch"],
13
"properties": {
14
"targetRepo": {
15
"type": "string",
···
18
"targetBranch": {
19
"type": "string"
20
},
21
-
"sourceRepo": {
22
-
"type": "string",
23
-
"format": "at-uri"
24
-
},
25
"pullId": {
26
"type": "integer"
27
},
···
31
"body": {
32
"type": "string"
33
},
34
"createdAt": {
35
"type": "string",
36
"format": "datetime"
37
-
},
38
-
"patch": {
39
-
"type": "string"
40
}
41
}
42
}
43
}
···
9
"key": "tid",
10
"record": {
11
"type": "object",
12
+
"required": [
13
+
"targetRepo",
14
+
"targetBranch",
15
+
"pullId",
16
+
"title",
17
+
"patch",
18
+
"createdAt"
19
+
],
20
"properties": {
21
"targetRepo": {
22
"type": "string",
···
25
"targetBranch": {
26
"type": "string"
27
},
28
"pullId": {
29
"type": "integer"
30
},
···
34
"body": {
35
"type": "string"
36
},
37
+
"patch": {
38
+
"type": "string"
39
+
},
40
+
"source": {
41
+
"type": "ref",
42
+
"ref": "#source"
43
+
},
44
"createdAt": {
45
"type": "string",
46
"format": "datetime"
47
}
48
+
}
49
+
}
50
+
},
51
+
"source": {
52
+
"type": "object",
53
+
"required": [
54
+
"branch"
55
+
],
56
+
"properties": {
57
+
"branch": {
58
+
"type": "string"
59
+
},
60
+
"repo": {
61
+
"type": "string",
62
+
"format": "at-uri"
63
}
64
}
65
}
+4
-1
lexicons/pulls/state.json
+4
-1
lexicons/pulls/state.json
+17
-7
lexicons/repo.json
+17
-7
lexicons/repo.json
···
9
"key": "tid",
10
"record": {
11
"type": "object",
12
-
"required": ["name", "knot", "owner"],
13
"properties": {
14
"name": {
15
"type": "string",
···
23
"type": "string",
24
"description": "knot where the repo was created"
25
},
26
-
"addedAt": {
27
-
"type": "string",
28
-
"format": "datetime"
29
-
},
30
"description": {
31
"type": "string",
32
"format": "datetime",
33
-
"minLength": 1,
34
-
"maxLength": 140
35
}
36
}
37
}
···
9
"key": "tid",
10
"record": {
11
"type": "object",
12
+
"required": [
13
+
"name",
14
+
"knot",
15
+
"owner",
16
+
"createdAt"
17
+
],
18
"properties": {
19
"name": {
20
"type": "string",
···
28
"type": "string",
29
"description": "knot where the repo was created"
30
},
31
"description": {
32
"type": "string",
33
"format": "datetime",
34
+
"minGraphemes": 1,
35
+
"maxGraphemes": 140
36
+
},
37
+
"source": {
38
+
"type": "string",
39
+
"format": "uri",
40
+
"description": "source of the repo"
41
+
},
42
+
"createdAt": {
43
+
"type": "string",
44
+
"format": "datetime"
45
}
46
}
47
}
-31
lexicons/star.json
-31
lexicons/star.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.feed.star",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"createdAt",
14
-
"subject"
15
-
],
16
-
"properties": {
17
-
"createdAt": {
18
-
"type": "string",
19
-
"format": "datetime"
20
-
},
21
-
"subject": {
22
-
"type": "string",
23
-
"format": "at-uri"
24
-
}
25
-
}
26
-
}
27
-
}
28
-
}
29
-
}
30
-
31
-
···
+172
patchutil/combinediff.go
+172
patchutil/combinediff.go
···
···
1
+
package patchutil
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
+
)
9
+
10
+
// original1 -> patch1 -> rev1
11
+
// original2 -> patch2 -> rev2
12
+
//
13
+
// original2 must be equal to rev1, so we can merge them to get maximal context
14
+
//
15
+
// finally,
16
+
// rev2' <- apply(patch2, merged)
17
+
// combineddiff <- diff(rev2', original1)
18
+
func combineFiles(file1, file2 *gitdiff.File) (*gitdiff.File, error) {
19
+
fileName := bestName(file1)
20
+
21
+
o1 := CreatePreImage(file1)
22
+
r1 := CreatePostImage(file1)
23
+
o2 := CreatePreImage(file2)
24
+
25
+
merged, err := r1.Merge(&o2)
26
+
if err != nil {
27
+
return nil, err
28
+
}
29
+
30
+
r2Prime, err := merged.Apply(file2)
31
+
if err != nil {
32
+
return nil, err
33
+
}
34
+
35
+
// produce combined diff
36
+
diff, err := Unified(o1.String(), fileName, r2Prime, fileName)
37
+
if err != nil {
38
+
return nil, err
39
+
}
40
+
41
+
parsed, _, err := gitdiff.Parse(strings.NewReader(diff))
42
+
43
+
if len(parsed) != 1 {
44
+
// no diff? the second commit reverted the changes from the first
45
+
return nil, nil
46
+
}
47
+
48
+
return parsed[0], nil
49
+
}
50
+
51
+
// use empty lines for lines we are unaware of
52
+
//
53
+
// this raises an error only if the two patches were invalid or non-contiguous
54
+
func mergeLines(old, new string) (string, error) {
55
+
var i, j int
56
+
57
+
// TODO: use strings.Lines
58
+
linesOld := strings.Split(old, "\n")
59
+
linesNew := strings.Split(new, "\n")
60
+
61
+
result := []string{}
62
+
63
+
for i < len(linesOld) || j < len(linesNew) {
64
+
if i >= len(linesOld) {
65
+
// rest of the file is populated from `new`
66
+
result = append(result, linesNew[j])
67
+
j++
68
+
continue
69
+
}
70
+
71
+
if j >= len(linesNew) {
72
+
// rest of the file is populated from `old`
73
+
result = append(result, linesOld[i])
74
+
i++
75
+
continue
76
+
}
77
+
78
+
oldLine := linesOld[i]
79
+
newLine := linesNew[j]
80
+
81
+
if oldLine != newLine && (oldLine != "" && newLine != "") {
82
+
// context mismatch
83
+
return "", fmt.Errorf("failed to merge files, found context mismatch at %d; oldLine: `%s`, newline: `%s`", i+1, oldLine, newLine)
84
+
}
85
+
86
+
if oldLine == newLine {
87
+
result = append(result, oldLine)
88
+
} else if oldLine == "" {
89
+
result = append(result, newLine)
90
+
} else if newLine == "" {
91
+
result = append(result, oldLine)
92
+
}
93
+
i++
94
+
j++
95
+
}
96
+
97
+
return strings.Join(result, "\n"), nil
98
+
}
99
+
100
+
func combineTwo(patch1, patch2 []*gitdiff.File) []*gitdiff.File {
101
+
fileToIdx1 := make(map[string]int)
102
+
fileToIdx2 := make(map[string]int)
103
+
visited := make(map[string]struct{})
104
+
var result []*gitdiff.File
105
+
106
+
for idx, f := range patch1 {
107
+
fileToIdx1[bestName(f)] = idx
108
+
}
109
+
110
+
for idx, f := range patch2 {
111
+
fileToIdx2[bestName(f)] = idx
112
+
}
113
+
114
+
for _, f1 := range patch1 {
115
+
fileName := bestName(f1)
116
+
if idx, ok := fileToIdx2[fileName]; ok {
117
+
f2 := patch2[idx]
118
+
119
+
// we have f1 and f2, combine them
120
+
combined, err := combineFiles(f1, f2)
121
+
if err != nil {
122
+
fmt.Println(err)
123
+
}
124
+
125
+
// combined can be nil commit 2 reverted all changes from commit 1
126
+
if combined != nil {
127
+
result = append(result, combined)
128
+
}
129
+
130
+
} else {
131
+
// only in patch1; add as-is
132
+
result = append(result, f1)
133
+
}
134
+
135
+
visited[fileName] = struct{}{}
136
+
}
137
+
138
+
// for all files in patch2 that remain unvisited; we can just add them into the output
139
+
for _, f2 := range patch2 {
140
+
fileName := bestName(f2)
141
+
if _, ok := visited[fileName]; ok {
142
+
continue
143
+
}
144
+
145
+
result = append(result, f2)
146
+
}
147
+
148
+
return result
149
+
}
150
+
151
+
// pairwise combination from first to last patch
152
+
func CombineDiff(patches ...[]*gitdiff.File) []*gitdiff.File {
153
+
if len(patches) == 0 {
154
+
return nil
155
+
}
156
+
157
+
if len(patches) == 1 {
158
+
return patches[0]
159
+
}
160
+
161
+
combined := combineTwo(patches[0], patches[1])
162
+
163
+
newPatches := [][]*gitdiff.File{}
164
+
newPatches = append(newPatches, combined)
165
+
for i, p := range patches {
166
+
if i >= 2 {
167
+
newPatches = append(newPatches, p)
168
+
}
169
+
}
170
+
171
+
return CombineDiff(newPatches...)
172
+
}
+178
patchutil/image.go
+178
patchutil/image.go
···
···
1
+
package patchutil
2
+
3
+
import (
4
+
"bytes"
5
+
"fmt"
6
+
"strings"
7
+
8
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
9
+
)
10
+
11
+
type Line struct {
12
+
LineNumber int64
13
+
Content string
14
+
IsUnknown bool
15
+
}
16
+
17
+
func NewLineAt(lineNumber int64, content string) Line {
18
+
return Line{
19
+
LineNumber: lineNumber,
20
+
Content: content,
21
+
IsUnknown: false,
22
+
}
23
+
}
24
+
25
+
type Image struct {
26
+
File string
27
+
Data []*Line
28
+
}
29
+
30
+
func (r *Image) String() string {
31
+
var i, j int64
32
+
var b strings.Builder
33
+
for {
34
+
i += 1
35
+
36
+
if int(j) >= (len(r.Data)) {
37
+
break
38
+
}
39
+
40
+
if r.Data[j].LineNumber == i {
41
+
// b.WriteString(fmt.Sprintf("%d:", r.Data[j].LineNumber))
42
+
b.WriteString(r.Data[j].Content)
43
+
j += 1
44
+
} else {
45
+
//b.WriteString(fmt.Sprintf("%d:\n", i))
46
+
b.WriteString("\n")
47
+
}
48
+
}
49
+
50
+
return b.String()
51
+
}
52
+
53
+
func (r *Image) AddLine(line *Line) {
54
+
r.Data = append(r.Data, line)
55
+
}
56
+
57
+
// rebuild the original file from a patch
58
+
func CreatePreImage(file *gitdiff.File) Image {
59
+
rf := Image{
60
+
File: bestName(file),
61
+
}
62
+
63
+
for _, fragment := range file.TextFragments {
64
+
position := fragment.OldPosition
65
+
for _, line := range fragment.Lines {
66
+
switch line.Op {
67
+
case gitdiff.OpContext:
68
+
rl := NewLineAt(position, line.Line)
69
+
rf.Data = append(rf.Data, &rl)
70
+
position += 1
71
+
case gitdiff.OpDelete:
72
+
rl := NewLineAt(position, line.Line)
73
+
rf.Data = append(rf.Data, &rl)
74
+
position += 1
75
+
case gitdiff.OpAdd:
76
+
// do nothing here
77
+
}
78
+
}
79
+
}
80
+
81
+
return rf
82
+
}
83
+
84
+
// rebuild the revised file from a patch
85
+
func CreatePostImage(file *gitdiff.File) Image {
86
+
rf := Image{
87
+
File: bestName(file),
88
+
}
89
+
90
+
for _, fragment := range file.TextFragments {
91
+
position := fragment.NewPosition
92
+
for _, line := range fragment.Lines {
93
+
switch line.Op {
94
+
case gitdiff.OpContext:
95
+
rl := NewLineAt(position, line.Line)
96
+
rf.Data = append(rf.Data, &rl)
97
+
position += 1
98
+
case gitdiff.OpAdd:
99
+
rl := NewLineAt(position, line.Line)
100
+
rf.Data = append(rf.Data, &rl)
101
+
position += 1
102
+
case gitdiff.OpDelete:
103
+
// do nothing here
104
+
}
105
+
}
106
+
}
107
+
108
+
return rf
109
+
}
110
+
111
+
type MergeError struct {
112
+
msg string
113
+
mismatchingLine int64
114
+
}
115
+
116
+
func (m MergeError) Error() string {
117
+
return fmt.Sprintf("%s: %v", m.msg, m.mismatchingLine)
118
+
}
119
+
120
+
// best effort merging of two reconstructed files
121
+
func (this *Image) Merge(other *Image) (*Image, error) {
122
+
mergedFile := Image{}
123
+
124
+
var i, j int64
125
+
126
+
for int(i) < len(this.Data) || int(j) < len(other.Data) {
127
+
if int(i) >= len(this.Data) {
128
+
// first file is done; the rest of the lines from file 2 can go in
129
+
mergedFile.AddLine(other.Data[j])
130
+
j++
131
+
continue
132
+
}
133
+
134
+
if int(j) >= len(other.Data) {
135
+
// first file is done; the rest of the lines from file 2 can go in
136
+
mergedFile.AddLine(this.Data[i])
137
+
i++
138
+
continue
139
+
}
140
+
141
+
line1 := this.Data[i]
142
+
line2 := other.Data[j]
143
+
144
+
if line1.LineNumber == line2.LineNumber {
145
+
if line1.Content != line2.Content {
146
+
return nil, MergeError{
147
+
msg: "mismatching lines, this patch might have undergone rebase",
148
+
mismatchingLine: line1.LineNumber,
149
+
}
150
+
} else {
151
+
mergedFile.AddLine(line1)
152
+
}
153
+
i++
154
+
j++
155
+
} else if line1.LineNumber < line2.LineNumber {
156
+
mergedFile.AddLine(line1)
157
+
i++
158
+
} else {
159
+
mergedFile.AddLine(line2)
160
+
j++
161
+
}
162
+
}
163
+
164
+
return &mergedFile, nil
165
+
}
166
+
167
+
func (r *Image) Apply(patch *gitdiff.File) (string, error) {
168
+
original := r.String()
169
+
var buffer bytes.Buffer
170
+
reader := strings.NewReader(original)
171
+
172
+
err := gitdiff.Apply(&buffer, reader, patch)
173
+
if err != nil {
174
+
return "", err
175
+
}
176
+
177
+
return buffer.String(), nil
178
+
}
+244
patchutil/interdiff.go
+244
patchutil/interdiff.go
···
···
1
+
package patchutil
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
+
)
9
+
10
+
type InterdiffResult struct {
11
+
Files []*InterdiffFile
12
+
}
13
+
14
+
func (i *InterdiffResult) AffectedFiles() []string {
15
+
files := make([]string, len(i.Files))
16
+
for _, f := range i.Files {
17
+
files = append(files, f.Name)
18
+
}
19
+
return files
20
+
}
21
+
22
+
func (i *InterdiffResult) String() string {
23
+
var b strings.Builder
24
+
for _, f := range i.Files {
25
+
b.WriteString(f.String())
26
+
b.WriteString("\n")
27
+
}
28
+
29
+
return b.String()
30
+
}
31
+
32
+
type InterdiffFile struct {
33
+
*gitdiff.File
34
+
Name string
35
+
Status InterdiffFileStatus
36
+
}
37
+
38
+
func (s *InterdiffFile) String() string {
39
+
var b strings.Builder
40
+
b.WriteString(s.Status.String())
41
+
b.WriteString(" ")
42
+
43
+
if s.File != nil {
44
+
b.WriteString(bestName(s.File))
45
+
b.WriteString("\n")
46
+
b.WriteString(s.File.String())
47
+
}
48
+
49
+
return b.String()
50
+
}
51
+
52
+
type InterdiffFileStatus struct {
53
+
StatusKind StatusKind
54
+
Error error
55
+
}
56
+
57
+
func (s *InterdiffFileStatus) String() string {
58
+
kind := s.StatusKind.String()
59
+
if s.Error != nil {
60
+
return fmt.Sprintf("%s [%s]", kind, s.Error.Error())
61
+
} else {
62
+
return kind
63
+
}
64
+
}
65
+
66
+
func (s *InterdiffFileStatus) IsOk() bool {
67
+
return s.StatusKind == StatusOk
68
+
}
69
+
70
+
func (s *InterdiffFileStatus) IsUnchanged() bool {
71
+
return s.StatusKind == StatusUnchanged
72
+
}
73
+
74
+
func (s *InterdiffFileStatus) IsOnlyInOne() bool {
75
+
return s.StatusKind == StatusOnlyInOne
76
+
}
77
+
78
+
func (s *InterdiffFileStatus) IsOnlyInTwo() bool {
79
+
return s.StatusKind == StatusOnlyInTwo
80
+
}
81
+
82
+
func (s *InterdiffFileStatus) IsRebased() bool {
83
+
return s.StatusKind == StatusRebased
84
+
}
85
+
86
+
func (s *InterdiffFileStatus) IsError() bool {
87
+
return s.StatusKind == StatusError
88
+
}
89
+
90
+
type StatusKind int
91
+
92
+
func (k StatusKind) String() string {
93
+
switch k {
94
+
case StatusOnlyInOne:
95
+
return "only in one"
96
+
case StatusOnlyInTwo:
97
+
return "only in two"
98
+
case StatusUnchanged:
99
+
return "unchanged"
100
+
case StatusRebased:
101
+
return "rebased"
102
+
case StatusError:
103
+
return "error"
104
+
default:
105
+
return "changed"
106
+
}
107
+
}
108
+
109
+
const (
110
+
StatusOk StatusKind = iota
111
+
StatusOnlyInOne
112
+
StatusOnlyInTwo
113
+
StatusUnchanged
114
+
StatusRebased
115
+
StatusError
116
+
)
117
+
118
+
func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile {
119
+
re1 := CreatePreImage(f1)
120
+
re2 := CreatePreImage(f2)
121
+
122
+
interdiffFile := InterdiffFile{
123
+
Name: bestName(f1),
124
+
}
125
+
126
+
merged, err := re1.Merge(&re2)
127
+
if err != nil {
128
+
interdiffFile.Status = InterdiffFileStatus{
129
+
StatusKind: StatusRebased,
130
+
Error: err,
131
+
}
132
+
return &interdiffFile
133
+
}
134
+
135
+
rev1, err := merged.Apply(f1)
136
+
if err != nil {
137
+
interdiffFile.Status = InterdiffFileStatus{
138
+
StatusKind: StatusError,
139
+
Error: err,
140
+
}
141
+
return &interdiffFile
142
+
}
143
+
144
+
rev2, err := merged.Apply(f2)
145
+
if err != nil {
146
+
interdiffFile.Status = InterdiffFileStatus{
147
+
StatusKind: StatusError,
148
+
Error: err,
149
+
}
150
+
return &interdiffFile
151
+
}
152
+
153
+
diff, err := Unified(rev1, bestName(f1), rev2, bestName(f2))
154
+
if err != nil {
155
+
interdiffFile.Status = InterdiffFileStatus{
156
+
StatusKind: StatusError,
157
+
Error: err,
158
+
}
159
+
return &interdiffFile
160
+
}
161
+
162
+
parsed, _, err := gitdiff.Parse(strings.NewReader(diff))
163
+
if err != nil {
164
+
interdiffFile.Status = InterdiffFileStatus{
165
+
StatusKind: StatusError,
166
+
Error: err,
167
+
}
168
+
return &interdiffFile
169
+
}
170
+
171
+
if len(parsed) != 1 {
172
+
// files are identical?
173
+
interdiffFile.Status = InterdiffFileStatus{
174
+
StatusKind: StatusUnchanged,
175
+
}
176
+
return &interdiffFile
177
+
}
178
+
179
+
if interdiffFile.Status.StatusKind == StatusOk {
180
+
interdiffFile.File = parsed[0]
181
+
}
182
+
183
+
return &interdiffFile
184
+
}
185
+
186
+
func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult {
187
+
fileToIdx1 := make(map[string]int)
188
+
fileToIdx2 := make(map[string]int)
189
+
visited := make(map[string]struct{})
190
+
var result InterdiffResult
191
+
192
+
for idx, f := range patch1 {
193
+
fileToIdx1[bestName(f)] = idx
194
+
}
195
+
196
+
for idx, f := range patch2 {
197
+
fileToIdx2[bestName(f)] = idx
198
+
}
199
+
200
+
for _, f1 := range patch1 {
201
+
var interdiffFile *InterdiffFile
202
+
203
+
fileName := bestName(f1)
204
+
if idx, ok := fileToIdx2[fileName]; ok {
205
+
f2 := patch2[idx]
206
+
207
+
// we have f1 and f2, calculate interdiff
208
+
interdiffFile = interdiffFiles(f1, f2)
209
+
} else {
210
+
// only in patch 1, this change would have to be "inverted" to dissapear
211
+
// from patch 2, so we reverseDiff(f1)
212
+
reverseDiff(f1)
213
+
214
+
interdiffFile = &InterdiffFile{
215
+
File: f1,
216
+
Name: fileName,
217
+
Status: InterdiffFileStatus{
218
+
StatusKind: StatusOnlyInOne,
219
+
},
220
+
}
221
+
}
222
+
223
+
result.Files = append(result.Files, interdiffFile)
224
+
visited[fileName] = struct{}{}
225
+
}
226
+
227
+
// for all files in patch2 that remain unvisited; we can just add them into the output
228
+
for _, f2 := range patch2 {
229
+
fileName := bestName(f2)
230
+
if _, ok := visited[fileName]; ok {
231
+
continue
232
+
}
233
+
234
+
result.Files = append(result.Files, &InterdiffFile{
235
+
File: f2,
236
+
Name: fileName,
237
+
Status: InterdiffFileStatus{
238
+
StatusKind: StatusOnlyInTwo,
239
+
},
240
+
})
241
+
}
242
+
243
+
return &result
244
+
}
+196
patchutil/patchutil.go
+196
patchutil/patchutil.go
···
···
1
+
package patchutil
2
+
3
+
import (
4
+
"fmt"
5
+
"os"
6
+
"os/exec"
7
+
"regexp"
8
+
"strings"
9
+
10
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
11
+
)
12
+
13
+
type FormatPatch struct {
14
+
Files []*gitdiff.File
15
+
*gitdiff.PatchHeader
16
+
}
17
+
18
+
func ExtractPatches(formatPatch string) ([]FormatPatch, error) {
19
+
patches := splitFormatPatch(formatPatch)
20
+
21
+
result := []FormatPatch{}
22
+
23
+
for _, patch := range patches {
24
+
files, headerStr, err := gitdiff.Parse(strings.NewReader(patch))
25
+
if err != nil {
26
+
return nil, fmt.Errorf("failed to parse patch: %w", err)
27
+
}
28
+
29
+
header, err := gitdiff.ParsePatchHeader(headerStr)
30
+
if err != nil {
31
+
return nil, fmt.Errorf("failed to parse patch header: %w", err)
32
+
}
33
+
34
+
result = append(result, FormatPatch{
35
+
Files: files,
36
+
PatchHeader: header,
37
+
})
38
+
}
39
+
40
+
return result, nil
41
+
}
42
+
43
+
// IsPatchValid checks if the given patch string is valid.
44
+
// It performs very basic sniffing for either git-diff or git-format-patch
45
+
// header lines. For format patches, it attempts to extract and validate each one.
46
+
func IsPatchValid(patch string) bool {
47
+
if len(patch) == 0 {
48
+
return false
49
+
}
50
+
51
+
lines := strings.Split(patch, "\n")
52
+
if len(lines) < 2 {
53
+
return false
54
+
}
55
+
56
+
firstLine := strings.TrimSpace(lines[0])
57
+
58
+
// check if it's a git diff
59
+
if strings.HasPrefix(firstLine, "diff ") ||
60
+
strings.HasPrefix(firstLine, "--- ") ||
61
+
strings.HasPrefix(firstLine, "Index: ") ||
62
+
strings.HasPrefix(firstLine, "+++ ") ||
63
+
strings.HasPrefix(firstLine, "@@ ") {
64
+
return true
65
+
}
66
+
67
+
// check if it's format-patch
68
+
if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") ||
69
+
strings.HasPrefix(firstLine, "From: ") {
70
+
// ExtractPatches already runs it through gitdiff.Parse so if that errors,
71
+
// it's safe to say it's broken.
72
+
patches, err := ExtractPatches(patch)
73
+
if err != nil {
74
+
return false
75
+
}
76
+
return len(patches) > 0
77
+
}
78
+
79
+
return false
80
+
}
81
+
82
+
func IsFormatPatch(patch string) bool {
83
+
lines := strings.Split(patch, "\n")
84
+
if len(lines) < 2 {
85
+
return false
86
+
}
87
+
88
+
firstLine := strings.TrimSpace(lines[0])
89
+
if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") {
90
+
return true
91
+
}
92
+
93
+
headerCount := 0
94
+
for i := range min(10, len(lines)) {
95
+
line := strings.TrimSpace(lines[i])
96
+
if strings.HasPrefix(line, "From: ") ||
97
+
strings.HasPrefix(line, "Date: ") ||
98
+
strings.HasPrefix(line, "Subject: ") ||
99
+
strings.HasPrefix(line, "commit ") {
100
+
headerCount++
101
+
}
102
+
}
103
+
104
+
return headerCount >= 2
105
+
}
106
+
107
+
func splitFormatPatch(patchText string) []string {
108
+
re := regexp.MustCompile(`(?m)^From [0-9a-f]{40} .*$`)
109
+
110
+
indexes := re.FindAllStringIndex(patchText, -1)
111
+
112
+
if len(indexes) == 0 {
113
+
return []string{}
114
+
}
115
+
116
+
patches := make([]string, len(indexes))
117
+
118
+
for i := range indexes {
119
+
startPos := indexes[i][0]
120
+
endPos := len(patchText)
121
+
122
+
if i < len(indexes)-1 {
123
+
endPos = indexes[i+1][0]
124
+
}
125
+
126
+
patches[i] = strings.TrimSpace(patchText[startPos:endPos])
127
+
}
128
+
return patches
129
+
}
130
+
131
+
func bestName(file *gitdiff.File) string {
132
+
if file.IsDelete {
133
+
return file.OldName
134
+
} else {
135
+
return file.NewName
136
+
}
137
+
}
138
+
139
+
// in-place reverse of a diff
140
+
func reverseDiff(file *gitdiff.File) {
141
+
file.OldName, file.NewName = file.NewName, file.OldName
142
+
file.OldMode, file.NewMode = file.NewMode, file.OldMode
143
+
file.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment
144
+
145
+
for _, fragment := range file.TextFragments {
146
+
// swap postions
147
+
fragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition
148
+
fragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines
149
+
fragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded
150
+
151
+
for i := range fragment.Lines {
152
+
switch fragment.Lines[i].Op {
153
+
case gitdiff.OpAdd:
154
+
fragment.Lines[i].Op = gitdiff.OpDelete
155
+
case gitdiff.OpDelete:
156
+
fragment.Lines[i].Op = gitdiff.OpAdd
157
+
default:
158
+
// do nothing
159
+
}
160
+
}
161
+
}
162
+
}
163
+
164
+
func Unified(oldText, oldFile, newText, newFile string) (string, error) {
165
+
oldTemp, err := os.CreateTemp("", "old_*")
166
+
if err != nil {
167
+
return "", fmt.Errorf("failed to create temp file for oldText: %w", err)
168
+
}
169
+
defer os.Remove(oldTemp.Name())
170
+
if _, err := oldTemp.WriteString(oldText); err != nil {
171
+
return "", fmt.Errorf("failed to write to old temp file: %w", err)
172
+
}
173
+
oldTemp.Close()
174
+
175
+
newTemp, err := os.CreateTemp("", "new_*")
176
+
if err != nil {
177
+
return "", fmt.Errorf("failed to create temp file for newText: %w", err)
178
+
}
179
+
defer os.Remove(newTemp.Name())
180
+
if _, err := newTemp.WriteString(newText); err != nil {
181
+
return "", fmt.Errorf("failed to write to new temp file: %w", err)
182
+
}
183
+
newTemp.Close()
184
+
185
+
cmd := exec.Command("diff", "-u", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name())
186
+
output, err := cmd.CombinedOutput()
187
+
188
+
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
189
+
return string(output), nil
190
+
}
191
+
if err != nil {
192
+
return "", fmt.Errorf("diff command failed: %w", err)
193
+
}
194
+
195
+
return string(output), nil
196
+
}
+324
patchutil/patchutil_test.go
+324
patchutil/patchutil_test.go
···
···
1
+
package patchutil
2
+
3
+
import (
4
+
"reflect"
5
+
"testing"
6
+
)
7
+
8
+
func TestIsPatchValid(t *testing.T) {
9
+
tests := []struct {
10
+
name string
11
+
patch string
12
+
expected bool
13
+
}{
14
+
{
15
+
name: `empty patch`,
16
+
patch: ``,
17
+
expected: false,
18
+
},
19
+
{
20
+
name: `single line patch`,
21
+
patch: `single line`,
22
+
expected: false,
23
+
},
24
+
{
25
+
name: `valid diff patch`,
26
+
patch: `diff --git a/file.txt b/file.txt
27
+
index abc..def 100644
28
+
--- a/file.txt
29
+
+++ b/file.txt
30
+
@@ -1,3 +1,3 @@
31
+
-old line
32
+
+new line
33
+
context`,
34
+
expected: true,
35
+
},
36
+
{
37
+
name: `valid patch starting with ---`,
38
+
patch: `--- a/file.txt
39
+
+++ b/file.txt
40
+
@@ -1,3 +1,3 @@
41
+
-old line
42
+
+new line
43
+
context`,
44
+
expected: true,
45
+
},
46
+
{
47
+
name: `valid patch starting with Index`,
48
+
patch: `Index: file.txt
49
+
==========
50
+
--- a/file.txt
51
+
+++ b/file.txt
52
+
@@ -1,3 +1,3 @@
53
+
-old line
54
+
+new line
55
+
context`,
56
+
expected: true,
57
+
},
58
+
{
59
+
name: `valid patch starting with +++`,
60
+
patch: `+++ b/file.txt
61
+
--- a/file.txt
62
+
@@ -1,3 +1,3 @@
63
+
-old line
64
+
+new line
65
+
context`,
66
+
expected: true,
67
+
},
68
+
{
69
+
name: `valid patch starting with @@`,
70
+
patch: `@@ -1,3 +1,3 @@
71
+
-old line
72
+
+new line
73
+
context
74
+
`,
75
+
expected: true,
76
+
},
77
+
{
78
+
name: `valid format patch`,
79
+
patch: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
80
+
From: Author <author@example.com>
81
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
82
+
Subject: [PATCH] Example patch
83
+
84
+
diff --git a/file.txt b/file.txt
85
+
index 123456..789012 100644
86
+
--- a/file.txt
87
+
+++ b/file.txt
88
+
@@ -1 +1 @@
89
+
-old content
90
+
+new content
91
+
--
92
+
2.48.1`,
93
+
expected: true,
94
+
},
95
+
{
96
+
name: `invalid format patch`,
97
+
patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001
98
+
From: Author <author@example.com>
99
+
This is not a valid patch format`,
100
+
expected: false,
101
+
},
102
+
{
103
+
name: `not a patch at all`,
104
+
patch: `This is
105
+
just some
106
+
random text
107
+
that isn't a patch`,
108
+
expected: false,
109
+
},
110
+
}
111
+
112
+
for _, tt := range tests {
113
+
t.Run(tt.name, func(t *testing.T) {
114
+
result := IsPatchValid(tt.patch)
115
+
if result != tt.expected {
116
+
t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected)
117
+
}
118
+
})
119
+
}
120
+
}
121
+
122
+
func TestSplitPatches(t *testing.T) {
123
+
tests := []struct {
124
+
name string
125
+
input string
126
+
expected []string
127
+
}{
128
+
{
129
+
name: "Empty input",
130
+
input: "",
131
+
expected: []string{},
132
+
},
133
+
{
134
+
name: "No valid patches",
135
+
input: "This is not a \nJust some random text",
136
+
expected: []string{},
137
+
},
138
+
{
139
+
name: "Single patch",
140
+
input: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
141
+
From: Author <author@example.com>
142
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
143
+
Subject: [PATCH] Example patch
144
+
145
+
diff --git a/file.txt b/file.txt
146
+
index 123456..789012 100644
147
+
--- a/file.txt
148
+
+++ b/file.txt
149
+
@@ -1 +1 @@
150
+
-old content
151
+
+new content
152
+
--
153
+
2.48.1`,
154
+
expected: []string{
155
+
`From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
156
+
From: Author <author@example.com>
157
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
158
+
Subject: [PATCH] Example patch
159
+
160
+
diff --git a/file.txt b/file.txt
161
+
index 123456..789012 100644
162
+
--- a/file.txt
163
+
+++ b/file.txt
164
+
@@ -1 +1 @@
165
+
-old content
166
+
+new content
167
+
--
168
+
2.48.1`,
169
+
},
170
+
},
171
+
{
172
+
name: "Two patches",
173
+
input: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
174
+
From: Author <author@example.com>
175
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
176
+
Subject: [PATCH 1/2] First patch
177
+
178
+
diff --git a/file1.txt b/file1.txt
179
+
index 123456..789012 100644
180
+
--- a/file1.txt
181
+
+++ b/file1.txt
182
+
@@ -1 +1 @@
183
+
-old content
184
+
+new content
185
+
--
186
+
2.48.1
187
+
From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
188
+
From: Author <author@example.com>
189
+
Date: Wed, 16 Apr 2025 11:03:11 +0300
190
+
Subject: [PATCH 2/2] Second patch
191
+
192
+
diff --git a/file2.txt b/file2.txt
193
+
index abcdef..ghijkl 100644
194
+
--- a/file2.txt
195
+
+++ b/file2.txt
196
+
@@ -1 +1 @@
197
+
-foo bar
198
+
+baz qux
199
+
--
200
+
2.48.1`,
201
+
expected: []string{
202
+
`From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
203
+
From: Author <author@example.com>
204
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
205
+
Subject: [PATCH 1/2] First patch
206
+
207
+
diff --git a/file1.txt b/file1.txt
208
+
index 123456..789012 100644
209
+
--- a/file1.txt
210
+
+++ b/file1.txt
211
+
@@ -1 +1 @@
212
+
-old content
213
+
+new content
214
+
--
215
+
2.48.1`,
216
+
`From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
217
+
From: Author <author@example.com>
218
+
Date: Wed, 16 Apr 2025 11:03:11 +0300
219
+
Subject: [PATCH 2/2] Second patch
220
+
221
+
diff --git a/file2.txt b/file2.txt
222
+
index abcdef..ghijkl 100644
223
+
--- a/file2.txt
224
+
+++ b/file2.txt
225
+
@@ -1 +1 @@
226
+
-foo bar
227
+
+baz qux
228
+
--
229
+
2.48.1`,
230
+
},
231
+
},
232
+
{
233
+
name: "Patches with additional text between them",
234
+
input: `Some text before the patches
235
+
236
+
From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
237
+
From: Author <author@example.com>
238
+
Subject: [PATCH] First patch
239
+
240
+
diff content here
241
+
--
242
+
2.48.1
243
+
244
+
Some text between patches
245
+
246
+
From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
247
+
From: Author <author@example.com>
248
+
Subject: [PATCH] Second patch
249
+
250
+
more diff content
251
+
--
252
+
2.48.1
253
+
254
+
Text after patches`,
255
+
expected: []string{
256
+
`From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
257
+
From: Author <author@example.com>
258
+
Subject: [PATCH] First patch
259
+
260
+
diff content here
261
+
--
262
+
2.48.1
263
+
264
+
Some text between patches`,
265
+
`From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
266
+
From: Author <author@example.com>
267
+
Subject: [PATCH] Second patch
268
+
269
+
more diff content
270
+
--
271
+
2.48.1
272
+
273
+
Text after patches`,
274
+
},
275
+
},
276
+
{
277
+
name: "Patches with whitespace padding",
278
+
input: `
279
+
280
+
From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
281
+
From: Author <author@example.com>
282
+
Subject: Patch
283
+
284
+
content
285
+
--
286
+
2.48.1
287
+
288
+
289
+
From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
290
+
From: Author <author@example.com>
291
+
Subject: Another patch
292
+
293
+
content
294
+
--
295
+
2.48.1
296
+
`,
297
+
expected: []string{
298
+
`From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
299
+
From: Author <author@example.com>
300
+
Subject: Patch
301
+
302
+
content
303
+
--
304
+
2.48.1`,
305
+
`From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
306
+
From: Author <author@example.com>
307
+
Subject: Another patch
308
+
309
+
content
310
+
--
311
+
2.48.1`,
312
+
},
313
+
},
314
+
}
315
+
316
+
for _, tt := range tests {
317
+
t.Run(tt.name, func(t *testing.T) {
318
+
result := splitFormatPatch(tt.input)
319
+
if !reflect.DeepEqual(result, tt.expected) {
320
+
t.Errorf("splitPatches() = %v, want %v", result, tt.expected)
321
+
}
322
+
})
323
+
}
324
+
}
+55
-34
rbac/rbac.go
+55
-34
rbac/rbac.go
···
3
import (
4
"database/sql"
5
"fmt"
6
-
"path"
7
"strings"
8
9
adapter "github.com/Blank-Xu/sql-adapter"
···
26
e = some(where (p.eft == allow))
27
28
[matchers]
29
-
m = r.act == p.act && r.dom == p.dom && keyMatch2(r.obj, p.obj) && g(r.sub, p.sub, r.dom)
30
`
31
)
32
···
34
E *casbin.Enforcer
35
}
36
37
-
func keyMatch2(key1 string, key2 string) bool {
38
-
matched, _ := path.Match(key2, key1)
39
-
return matched
40
-
}
41
-
42
func NewEnforcer(path string) (*Enforcer, error) {
43
m, err := model.NewModelFromString(Model)
44
if err != nil {
···
61
}
62
63
e.EnableAutoSave(false)
64
-
65
-
e.AddFunction("keyMatch2", keyMatch2Func)
66
67
return &Enforcer{e}, nil
68
}
···
96
return err
97
}
98
99
-
func (e *Enforcer) AddRepo(member, domain, repo string) error {
100
-
// sanity check, repo must be of the form ownerDid/repo
101
-
if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") {
102
-
return fmt.Errorf("invalid repo: %s", repo)
103
-
}
104
-
105
-
_, err := e.E.AddPolicies([][]string{
106
{member, domain, repo, "repo:settings"},
107
{member, domain, repo, "repo:push"},
108
{member, domain, repo, "repo:owner"},
109
{member, domain, repo, "repo:invite"},
110
{member, domain, repo, "repo:delete"},
111
{"server:owner", domain, repo, "repo:delete"}, // server owner can delete any repo
112
-
})
113
return err
114
}
115
116
func (e *Enforcer) AddCollaborator(collaborator, domain, repo string) error {
117
-
// sanity check, repo must be of the form ownerDid/repo
118
-
if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") {
119
-
return fmt.Errorf("invalid repo: %s", repo)
120
}
121
122
-
_, err := e.E.AddPolicies([][]string{
123
-
{collaborator, domain, repo, "repo:collaborator"},
124
-
{collaborator, domain, repo, "repo:settings"},
125
-
{collaborator, domain, repo, "repo:push"},
126
-
})
127
return err
128
}
129
···
165
return e.E.Enforce(user, domain, repo, "repo:settings")
166
}
167
168
// given a repo, what permissions does this user have? repo:owner? repo:invite? etc.
169
func (e *Enforcer) GetPermissionsInRepo(user, domain, repo string) []string {
170
var permissions []string
···
179
return permissions
180
}
181
182
-
func (e *Enforcer) IsCollaboratorInviteAllowed(user, domain, repo string) (bool, error) {
183
-
return e.E.Enforce(user, domain, repo, "repo:invite")
184
-
}
185
186
-
// keyMatch2Func is a wrapper for keyMatch2 to make it compatible with Casbin
187
-
func keyMatch2Func(args ...interface{}) (interface{}, error) {
188
-
name1 := args[0].(string)
189
-
name2 := args[1].(string)
190
-
191
-
return keyMatch2(name1, name2), nil
192
}
···
3
import (
4
"database/sql"
5
"fmt"
6
"strings"
7
8
adapter "github.com/Blank-Xu/sql-adapter"
···
25
e = some(where (p.eft == allow))
26
27
[matchers]
28
+
m = r.act == p.act && r.dom == p.dom && r.obj == p.obj && g(r.sub, p.sub, r.dom)
29
`
30
)
31
···
33
E *casbin.Enforcer
34
}
35
36
func NewEnforcer(path string) (*Enforcer, error) {
37
m, err := model.NewModelFromString(Model)
38
if err != nil {
···
55
}
56
57
e.EnableAutoSave(false)
58
59
return &Enforcer{e}, nil
60
}
···
88
return err
89
}
90
91
+
func repoPolicies(member, domain, repo string) [][]string {
92
+
return [][]string{
93
{member, domain, repo, "repo:settings"},
94
{member, domain, repo, "repo:push"},
95
{member, domain, repo, "repo:owner"},
96
{member, domain, repo, "repo:invite"},
97
{member, domain, repo, "repo:delete"},
98
{"server:owner", domain, repo, "repo:delete"}, // server owner can delete any repo
99
+
}
100
+
}
101
+
func (e *Enforcer) AddRepo(member, domain, repo string) error {
102
+
err := checkRepoFormat(repo)
103
+
if err != nil {
104
+
return err
105
+
}
106
+
107
+
_, err = e.E.AddPolicies(repoPolicies(member, domain, repo))
108
return err
109
}
110
+
func (e *Enforcer) RemoveRepo(member, domain, repo string) error {
111
+
err := checkRepoFormat(repo)
112
+
if err != nil {
113
+
return err
114
+
}
115
+
116
+
_, err = e.E.RemovePolicies(repoPolicies(member, domain, repo))
117
+
return err
118
+
}
119
+
120
+
var (
121
+
collaboratorPolicies = func(collaborator, domain, repo string) [][]string {
122
+
return [][]string{
123
+
{collaborator, domain, repo, "repo:collaborator"},
124
+
{collaborator, domain, repo, "repo:settings"},
125
+
{collaborator, domain, repo, "repo:push"},
126
+
}
127
+
}
128
+
)
129
130
func (e *Enforcer) AddCollaborator(collaborator, domain, repo string) error {
131
+
err := checkRepoFormat(repo)
132
+
if err != nil {
133
+
return err
134
}
135
136
+
_, err = e.E.AddPolicies(collaboratorPolicies(collaborator, domain, repo))
137
+
return err
138
+
}
139
+
140
+
func (e *Enforcer) RemoveCollaborator(collaborator, domain, repo string) error {
141
+
err := checkRepoFormat(repo)
142
+
if err != nil {
143
+
return err
144
+
}
145
+
146
+
_, err = e.E.RemovePolicies(collaboratorPolicies(collaborator, domain, repo))
147
return err
148
}
149
···
185
return e.E.Enforce(user, domain, repo, "repo:settings")
186
}
187
188
+
func (e *Enforcer) IsCollaboratorInviteAllowed(user, domain, repo string) (bool, error) {
189
+
return e.E.Enforce(user, domain, repo, "repo:invite")
190
+
}
191
+
192
// given a repo, what permissions does this user have? repo:owner? repo:invite? etc.
193
func (e *Enforcer) GetPermissionsInRepo(user, domain, repo string) []string {
194
var permissions []string
···
203
return permissions
204
}
205
206
+
func checkRepoFormat(repo string) error {
207
+
// sanity check, repo must be of the form ownerDid/repo
208
+
if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") {
209
+
return fmt.Errorf("invalid repo: %s", repo)
210
+
}
211
212
+
return nil
213
}
+8
-89
readme.md
+8
-89
readme.md
···
6
7
Read the introduction to Tangled [here](https://blog.tangled.sh/intro).
8
9
-
## knot self-hosting guide
10
11
-
So you want to run your own knot server? Great! Here are a few prerequisites:
12
13
-
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind.
14
-
2. A (sub)domain name. People generally use `knot.example.com`.
15
-
3. A valid SSL certificate for your domain.
16
17
-
There's a couple of ways to get started:
18
-
* NixOS: refer to [flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix)
19
-
* Manual: Documented below.
20
-
21
-
### manual setup
22
-
23
-
First, clone this repository:
24
-
25
-
```
26
-
git clone https://tangled.sh/@tangled.sh/core
27
-
```
28
-
29
-
Then, build our binaries (you need to have Go installed):
30
-
* `knotserver`: the main server program
31
-
* `keyfetch`: utility to fetch ssh pubkeys
32
-
* `repoguard`: enforces repository access control
33
-
34
-
```
35
-
cd core
36
-
export CGO_ENABLED=1
37
-
go build -o knot ./cmd/knotserver
38
-
go build -o keyfetch ./cmd/keyfetch
39
-
go build -o repoguard ./cmd/repoguard
40
-
```
41
-
42
-
Next, move the `keyfetch` binary to a location owned by `root` --
43
-
`/usr/local/libexec/tangled-keyfetch` is a good choice:
44
-
45
-
```
46
-
sudo mv keyfetch /usr/local/libexec/tangled-keyfetch
47
-
sudo chown root:root /usr/local/libexec/tangled-keyfetch
48
-
sudo chmod 755 /usr/local/libexec/tangled-keyfetch
49
-
```
50
-
51
-
This is necessary because SSH `AuthorizedKeysCommand` requires [really specific
52
-
permissions](https://stackoverflow.com/a/27638306). Let's set that up:
53
-
54
-
```
55
-
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
56
-
Match User git
57
-
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch
58
-
AuthorizedKeysCommandUser nobody
59
-
EOF
60
-
```
61
-
62
-
Next, create the `git` user:
63
-
64
-
```
65
-
sudo adduser git
66
-
```
67
-
68
-
Copy the `repoguard` binary to the `git` user's home directory:
69
-
70
-
```
71
-
sudo cp repoguard /home/git
72
-
sudo chown git:git /home/git/repoguard
73
-
```
74
-
75
-
Now, let's set up the server. Copy the `knot` binary to
76
-
`/usr/local/bin/knotserver`. Then, create `/home/git/.knot.env` with the
77
-
following, updating the values as necessary. The `KNOT_SERVER_SECRET` can be
78
-
obtaind from the [/knots](/knots) page on Tangled.
79
-
80
-
```
81
-
KNOT_REPO_SCAN_PATH=/home/git
82
-
KNOT_SERVER_HOSTNAME=knot.example.com
83
-
APPVIEW_ENDPOINT=https://tangled.sh
84
-
KNOT_SERVER_SECRET=secret
85
-
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
86
-
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
87
-
```
88
-
89
-
If you run a Linux distribution that uses systemd, you can use the provided
90
-
service file to run the server. Copy
91
-
[`knotserver.service`](https://tangled.sh/did:plc:wshs7t2adsemcrrd4snkeqli/core/blob/master/systemd/knotserver.service)
92
-
to `/etc/systemd/system/`. Then, run:
93
-
94
-
```
95
-
systemctl enable knotserver
96
-
systemctl start knotserver
97
-
```
98
-
99
-
You should now have a running knot server! You can finalize your registration by hitting the
100
-
`initialize` button on the [/knots](/knots) page.
···
6
7
Read the introduction to Tangled [here](https://blog.tangled.sh/intro).
8
9
+
## docs
10
11
+
* [knot hosting
12
+
guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md)
13
+
* [contributing
14
+
guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/contributing.md)—**read this before opening a PR!**
15
16
+
## security
17
18
+
If you've identified a security issue in Tangled, please email
19
+
[security@tangled.sh](mailto:security@tangled.sh) with details!
+5
scripts/generate-jwks.sh
+5
scripts/generate-jwks.sh
+43
-9
tailwind.config.js
+43
-9
tailwind.config.js
···
1
/** @type {import('tailwindcss').Config} */
2
-
const colors = require('tailwindcss/colors')
3
4
module.exports = {
5
-
content: ["./appview/pages/templates/**/*.html"],
6
theme: {
7
container: {
8
padding: "2rem",
···
12
md: "600px",
13
lg: "800px",
14
xl: "1000px",
15
-
"2xl": "1200px"
16
},
17
},
18
extend: {
19
fontFamily: {
20
-
sans: ["iA Writer Quattro S", "Inter", "system-ui", "sans-serif", "ui-sans-serif"],
21
-
mono: ["iA Writer Mono S", "ui-monospace", "SFMono-Regular", "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", "monospace"],
22
},
23
typography: {
24
DEFAULT: {
25
css: {
26
-
maxWidth: 'none',
27
pre: {
28
backgroundColor: colors.gray[100],
29
color: colors.black,
30
},
31
},
32
},
33
},
34
},
35
},
36
-
plugins: [
37
-
require('@tailwindcss/typography'),
38
-
]
39
};
···
1
/** @type {import('tailwindcss').Config} */
2
+
const colors = require("tailwindcss/colors");
3
4
module.exports = {
5
+
content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"],
6
+
darkMode: "media",
7
theme: {
8
container: {
9
padding: "2rem",
···
13
md: "600px",
14
lg: "800px",
15
xl: "1000px",
16
+
"2xl": "1200px",
17
},
18
},
19
extend: {
20
fontFamily: {
21
+
sans: ["InterVariable", "system-ui", "sans-serif", "ui-sans-serif"],
22
+
mono: [
23
+
"IBMPlexMono",
24
+
"ui-monospace",
25
+
"SFMono-Regular",
26
+
"Menlo",
27
+
"Monaco",
28
+
"Consolas",
29
+
"Liberation Mono",
30
+
"Courier New",
31
+
"monospace",
32
+
],
33
},
34
typography: {
35
DEFAULT: {
36
css: {
37
+
maxWidth: "none",
38
pre: {
39
backgroundColor: colors.gray[100],
40
color: colors.black,
41
+
"@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {},
42
+
},
43
+
code: {
44
+
"@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
45
+
},
46
+
"code::before": {
47
+
content: '""',
48
+
},
49
+
"code::after": {
50
+
content: '""',
51
+
},
52
+
blockquote: {
53
+
quotes: "none",
54
+
},
55
+
'h1, h2, h3, h4': {
56
+
"@apply mt-4 mb-2": {}
57
+
},
58
+
h1: {
59
+
"@apply mt-3 pb-3 border-b border-gray-300 dark:border-gray-600": {}
60
+
},
61
+
h2: {
62
+
"@apply mt-3 pb-3 border-b border-gray-200 dark:border-gray-700": {}
63
+
},
64
+
h3: {
65
+
"@apply mt-2": {}
66
},
67
},
68
},
69
},
70
},
71
},
72
+
plugins: [require("@tailwindcss/typography")],
73
};
+10
types/capabilities.go
+10
types/capabilities.go
···
···
1
+
package types
2
+
3
+
type Capabilities struct {
4
+
PullRequests struct {
5
+
FormatPatch bool `json:"format_patch"`
6
+
PatchSubmissions bool `json:"patch_submissions"`
7
+
BranchSubmissions bool `json:"branch_submissions"`
8
+
ForkSubmissions bool `json:"fork_submissions"`
9
+
} `json:"pull_requests"`
10
+
}
+35
types/diff.go
+35
types/diff.go
···
23
IsRename bool `json:"is_rename"`
24
}
25
26
+
type DiffStat struct {
27
+
Insertions int64
28
+
Deletions int64
29
+
}
30
+
31
+
func (d *Diff) Stats() DiffStat {
32
+
var stats DiffStat
33
+
for _, f := range d.TextFragments {
34
+
stats.Insertions += f.LinesAdded
35
+
stats.Deletions += f.LinesDeleted
36
+
}
37
+
return stats
38
+
}
39
+
40
// A nicer git diff representation.
41
type NiceDiff struct {
42
Commit struct {
···
52
} `json:"stat"`
53
Diff []Diff `json:"diff"`
54
}
55
+
56
+
type DiffTree struct {
57
+
Rev1 string `json:"rev1"`
58
+
Rev2 string `json:"rev2"`
59
+
Patch string `json:"patch"`
60
+
Diff []*gitdiff.File `json:"diff"`
61
+
}
62
+
63
+
func (d *NiceDiff) ChangedFiles() []string {
64
+
files := make([]string, len(d.Diff))
65
+
66
+
for i, f := range d.Diff {
67
+
if f.IsDelete {
68
+
files[i] = f.Name.Old
69
+
} else {
70
+
files[i] = f.Name.New
71
+
}
72
+
}
73
+
74
+
return files
75
+
}
+18
types/repo.go
+18
types/repo.go
···
2
3
import (
4
"github.com/go-git/go-git/v5/plumbing/object"
5
)
6
7
type RepoIndexResponse struct {
···
32
Diff *NiceDiff `json:"diff,omitempty"`
33
}
34
35
type RepoTreeResponse struct {
36
Ref string `json:"ref,omitempty"`
37
Parent string `json:"parent,omitempty"`
···
53
54
type Branch struct {
55
Reference `json:"reference"`
56
}
57
58
type RepoTagsResponse struct {
···
61
62
type RepoBranchesResponse struct {
63
Branches []Branch `json:"branches,omitempty"`
64
}
65
66
type RepoBlobResponse struct {
···
2
3
import (
4
"github.com/go-git/go-git/v5/plumbing/object"
5
+
"tangled.sh/tangled.sh/core/patchutil"
6
)
7
8
type RepoIndexResponse struct {
···
33
Diff *NiceDiff `json:"diff,omitempty"`
34
}
35
36
+
type RepoFormatPatchResponse struct {
37
+
Rev1 string `json:"rev1,omitempty"`
38
+
Rev2 string `json:"rev2,omitempty"`
39
+
FormatPatch []patchutil.FormatPatch `json:"format_patch,omitempty"`
40
+
Patch string `json:"patch,omitempty"`
41
+
}
42
+
43
type RepoTreeResponse struct {
44
Ref string `json:"ref,omitempty"`
45
Parent string `json:"parent,omitempty"`
···
61
62
type Branch struct {
63
Reference `json:"reference"`
64
+
Commit *object.Commit `json:"commit,omitempty"`
65
+
IsDefault bool `json:"is_deafult,omitempty"`
66
}
67
68
type RepoTagsResponse struct {
···
71
72
type RepoBranchesResponse struct {
73
Branches []Branch `json:"branches,omitempty"`
74
+
}
75
+
76
+
type RepoBranchResponse struct {
77
+
Branch Branch `json:"branch,omitempty"`
78
+
}
79
+
80
+
type RepoDefaultBranchResponse struct {
81
+
Branch string `json:"branch,omitempty"`
82
}
83
84
type RepoBlobResponse struct {