+2
-1
.tangled/workflows/fmt.yml
+2
-1
.tangled/workflows/fmt.yml
+430
api/tangled/cbor_gen.go
+430
api/tangled/cbor_gen.go
···
5854
5855
return nil
5856
}
5857
+
func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error {
5858
+
if t == nil {
5859
+
_, err := w.Write(cbg.CborNull)
5860
+
return err
5861
+
}
5862
+
5863
+
cw := cbg.NewCborWriter(w)
5864
+
5865
+
if _, err := cw.Write([]byte{164}); err != nil {
5866
+
return err
5867
+
}
5868
+
5869
+
// t.Repo (string) (string)
5870
+
if len("repo") > 1000000 {
5871
+
return xerrors.Errorf("Value in field \"repo\" was too long")
5872
+
}
5873
+
5874
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
5875
+
return err
5876
+
}
5877
+
if _, err := cw.WriteString(string("repo")); err != nil {
5878
+
return err
5879
+
}
5880
+
5881
+
if len(t.Repo) > 1000000 {
5882
+
return xerrors.Errorf("Value in field t.Repo was too long")
5883
+
}
5884
+
5885
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil {
5886
+
return err
5887
+
}
5888
+
if _, err := cw.WriteString(string(t.Repo)); err != nil {
5889
+
return err
5890
+
}
5891
+
5892
+
// t.LexiconTypeID (string) (string)
5893
+
if len("$type") > 1000000 {
5894
+
return xerrors.Errorf("Value in field \"$type\" was too long")
5895
+
}
5896
+
5897
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
5898
+
return err
5899
+
}
5900
+
if _, err := cw.WriteString(string("$type")); err != nil {
5901
+
return err
5902
+
}
5903
+
5904
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil {
5905
+
return err
5906
+
}
5907
+
if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil {
5908
+
return err
5909
+
}
5910
+
5911
+
// t.Subject (string) (string)
5912
+
if len("subject") > 1000000 {
5913
+
return xerrors.Errorf("Value in field \"subject\" was too long")
5914
+
}
5915
+
5916
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
5917
+
return err
5918
+
}
5919
+
if _, err := cw.WriteString(string("subject")); err != nil {
5920
+
return err
5921
+
}
5922
+
5923
+
if len(t.Subject) > 1000000 {
5924
+
return xerrors.Errorf("Value in field t.Subject was too long")
5925
+
}
5926
+
5927
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
5928
+
return err
5929
+
}
5930
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
5931
+
return err
5932
+
}
5933
+
5934
+
// t.CreatedAt (string) (string)
5935
+
if len("createdAt") > 1000000 {
5936
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
5937
+
}
5938
+
5939
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
5940
+
return err
5941
+
}
5942
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
5943
+
return err
5944
+
}
5945
+
5946
+
if len(t.CreatedAt) > 1000000 {
5947
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
5948
+
}
5949
+
5950
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
5951
+
return err
5952
+
}
5953
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
5954
+
return err
5955
+
}
5956
+
return nil
5957
+
}
5958
+
5959
+
func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) {
5960
+
*t = RepoCollaborator{}
5961
+
5962
+
cr := cbg.NewCborReader(r)
5963
+
5964
+
maj, extra, err := cr.ReadHeader()
5965
+
if err != nil {
5966
+
return err
5967
+
}
5968
+
defer func() {
5969
+
if err == io.EOF {
5970
+
err = io.ErrUnexpectedEOF
5971
+
}
5972
+
}()
5973
+
5974
+
if maj != cbg.MajMap {
5975
+
return fmt.Errorf("cbor input should be of type map")
5976
+
}
5977
+
5978
+
if extra > cbg.MaxLength {
5979
+
return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra)
5980
+
}
5981
+
5982
+
n := extra
5983
+
5984
+
nameBuf := make([]byte, 9)
5985
+
for i := uint64(0); i < n; i++ {
5986
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
5987
+
if err != nil {
5988
+
return err
5989
+
}
5990
+
5991
+
if !ok {
5992
+
// Field doesn't exist on this type, so ignore it
5993
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
5994
+
return err
5995
+
}
5996
+
continue
5997
+
}
5998
+
5999
+
switch string(nameBuf[:nameLen]) {
6000
+
// t.Repo (string) (string)
6001
+
case "repo":
6002
+
6003
+
{
6004
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6005
+
if err != nil {
6006
+
return err
6007
+
}
6008
+
6009
+
t.Repo = string(sval)
6010
+
}
6011
+
// t.LexiconTypeID (string) (string)
6012
+
case "$type":
6013
+
6014
+
{
6015
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6016
+
if err != nil {
6017
+
return err
6018
+
}
6019
+
6020
+
t.LexiconTypeID = string(sval)
6021
+
}
6022
+
// t.Subject (string) (string)
6023
+
case "subject":
6024
+
6025
+
{
6026
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6027
+
if err != nil {
6028
+
return err
6029
+
}
6030
+
6031
+
t.Subject = string(sval)
6032
+
}
6033
+
// t.CreatedAt (string) (string)
6034
+
case "createdAt":
6035
+
6036
+
{
6037
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6038
+
if err != nil {
6039
+
return err
6040
+
}
6041
+
6042
+
t.CreatedAt = string(sval)
6043
+
}
6044
+
6045
+
default:
6046
+
// Field doesn't exist on this type, so ignore it
6047
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
6048
+
return err
6049
+
}
6050
+
}
6051
+
}
6052
+
6053
+
return nil
6054
+
}
6055
func (t *RepoIssue) MarshalCBOR(w io.Writer) error {
6056
if t == nil {
6057
_, err := w.Write(cbg.CborNull)
···
8423
8424
return nil
8425
}
8426
+
func (t *String) MarshalCBOR(w io.Writer) error {
8427
+
if t == nil {
8428
+
_, err := w.Write(cbg.CborNull)
8429
+
return err
8430
+
}
8431
+
8432
+
cw := cbg.NewCborWriter(w)
8433
+
8434
+
if _, err := cw.Write([]byte{165}); err != nil {
8435
+
return err
8436
+
}
8437
+
8438
+
// t.LexiconTypeID (string) (string)
8439
+
if len("$type") > 1000000 {
8440
+
return xerrors.Errorf("Value in field \"$type\" was too long")
8441
+
}
8442
+
8443
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
8444
+
return err
8445
+
}
8446
+
if _, err := cw.WriteString(string("$type")); err != nil {
8447
+
return err
8448
+
}
8449
+
8450
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil {
8451
+
return err
8452
+
}
8453
+
if _, err := cw.WriteString(string("sh.tangled.string")); err != nil {
8454
+
return err
8455
+
}
8456
+
8457
+
// t.Contents (string) (string)
8458
+
if len("contents") > 1000000 {
8459
+
return xerrors.Errorf("Value in field \"contents\" was too long")
8460
+
}
8461
+
8462
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil {
8463
+
return err
8464
+
}
8465
+
if _, err := cw.WriteString(string("contents")); err != nil {
8466
+
return err
8467
+
}
8468
+
8469
+
if len(t.Contents) > 1000000 {
8470
+
return xerrors.Errorf("Value in field t.Contents was too long")
8471
+
}
8472
+
8473
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil {
8474
+
return err
8475
+
}
8476
+
if _, err := cw.WriteString(string(t.Contents)); err != nil {
8477
+
return err
8478
+
}
8479
+
8480
+
// t.Filename (string) (string)
8481
+
if len("filename") > 1000000 {
8482
+
return xerrors.Errorf("Value in field \"filename\" was too long")
8483
+
}
8484
+
8485
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil {
8486
+
return err
8487
+
}
8488
+
if _, err := cw.WriteString(string("filename")); err != nil {
8489
+
return err
8490
+
}
8491
+
8492
+
if len(t.Filename) > 1000000 {
8493
+
return xerrors.Errorf("Value in field t.Filename was too long")
8494
+
}
8495
+
8496
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil {
8497
+
return err
8498
+
}
8499
+
if _, err := cw.WriteString(string(t.Filename)); err != nil {
8500
+
return err
8501
+
}
8502
+
8503
+
// t.CreatedAt (string) (string)
8504
+
if len("createdAt") > 1000000 {
8505
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
8506
+
}
8507
+
8508
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
8509
+
return err
8510
+
}
8511
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
8512
+
return err
8513
+
}
8514
+
8515
+
if len(t.CreatedAt) > 1000000 {
8516
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
8517
+
}
8518
+
8519
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
8520
+
return err
8521
+
}
8522
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
8523
+
return err
8524
+
}
8525
+
8526
+
// t.Description (string) (string)
8527
+
if len("description") > 1000000 {
8528
+
return xerrors.Errorf("Value in field \"description\" was too long")
8529
+
}
8530
+
8531
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil {
8532
+
return err
8533
+
}
8534
+
if _, err := cw.WriteString(string("description")); err != nil {
8535
+
return err
8536
+
}
8537
+
8538
+
if len(t.Description) > 1000000 {
8539
+
return xerrors.Errorf("Value in field t.Description was too long")
8540
+
}
8541
+
8542
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil {
8543
+
return err
8544
+
}
8545
+
if _, err := cw.WriteString(string(t.Description)); err != nil {
8546
+
return err
8547
+
}
8548
+
return nil
8549
+
}
8550
+
8551
+
func (t *String) UnmarshalCBOR(r io.Reader) (err error) {
8552
+
*t = String{}
8553
+
8554
+
cr := cbg.NewCborReader(r)
8555
+
8556
+
maj, extra, err := cr.ReadHeader()
8557
+
if err != nil {
8558
+
return err
8559
+
}
8560
+
defer func() {
8561
+
if err == io.EOF {
8562
+
err = io.ErrUnexpectedEOF
8563
+
}
8564
+
}()
8565
+
8566
+
if maj != cbg.MajMap {
8567
+
return fmt.Errorf("cbor input should be of type map")
8568
+
}
8569
+
8570
+
if extra > cbg.MaxLength {
8571
+
return fmt.Errorf("String: map struct too large (%d)", extra)
8572
+
}
8573
+
8574
+
n := extra
8575
+
8576
+
nameBuf := make([]byte, 11)
8577
+
for i := uint64(0); i < n; i++ {
8578
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
8579
+
if err != nil {
8580
+
return err
8581
+
}
8582
+
8583
+
if !ok {
8584
+
// Field doesn't exist on this type, so ignore it
8585
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
8586
+
return err
8587
+
}
8588
+
continue
8589
+
}
8590
+
8591
+
switch string(nameBuf[:nameLen]) {
8592
+
// t.LexiconTypeID (string) (string)
8593
+
case "$type":
8594
+
8595
+
{
8596
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8597
+
if err != nil {
8598
+
return err
8599
+
}
8600
+
8601
+
t.LexiconTypeID = string(sval)
8602
+
}
8603
+
// t.Contents (string) (string)
8604
+
case "contents":
8605
+
8606
+
{
8607
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8608
+
if err != nil {
8609
+
return err
8610
+
}
8611
+
8612
+
t.Contents = string(sval)
8613
+
}
8614
+
// t.Filename (string) (string)
8615
+
case "filename":
8616
+
8617
+
{
8618
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8619
+
if err != nil {
8620
+
return err
8621
+
}
8622
+
8623
+
t.Filename = string(sval)
8624
+
}
8625
+
// t.CreatedAt (string) (string)
8626
+
case "createdAt":
8627
+
8628
+
{
8629
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8630
+
if err != nil {
8631
+
return err
8632
+
}
8633
+
8634
+
t.CreatedAt = string(sval)
8635
+
}
8636
+
// t.Description (string) (string)
8637
+
case "description":
8638
+
8639
+
{
8640
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8641
+
if err != nil {
8642
+
return err
8643
+
}
8644
+
8645
+
t.Description = string(sval)
8646
+
}
8647
+
8648
+
default:
8649
+
// Field doesn't exist on this type, so ignore it
8650
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
8651
+
return err
8652
+
}
8653
+
}
8654
+
}
8655
+
8656
+
return nil
8657
+
}
+31
api/tangled/repoaddSecret.go
+31
api/tangled/repoaddSecret.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.addSecret
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoAddSecretNSID = "sh.tangled.repo.addSecret"
15
+
)
16
+
17
+
// RepoAddSecret_Input is the input argument to a sh.tangled.repo.addSecret call.
18
+
type RepoAddSecret_Input struct {
19
+
Key string `json:"key" cborgen:"key"`
20
+
Repo string `json:"repo" cborgen:"repo"`
21
+
Value string `json:"value" cborgen:"value"`
22
+
}
23
+
24
+
// RepoAddSecret calls the XRPC method "sh.tangled.repo.addSecret".
25
+
func RepoAddSecret(ctx context.Context, c util.LexClient, input *RepoAddSecret_Input) error {
26
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.addSecret", nil, input, nil); err != nil {
27
+
return err
28
+
}
29
+
30
+
return nil
31
+
}
+25
api/tangled/repocollaborator.go
+25
api/tangled/repocollaborator.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.collaborator
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
RepoCollaboratorNSID = "sh.tangled.repo.collaborator"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.repo.collaborator", &RepoCollaborator{})
17
+
} //
18
+
// RECORDTYPE: RepoCollaborator
19
+
type RepoCollaborator struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
+
// repo: repo to add this user to
23
+
Repo string `json:"repo" cborgen:"repo"`
24
+
Subject string `json:"subject" cborgen:"subject"`
25
+
}
+41
api/tangled/repolistSecrets.go
+41
api/tangled/repolistSecrets.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.listSecrets
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoListSecretsNSID = "sh.tangled.repo.listSecrets"
15
+
)
16
+
17
+
// RepoListSecrets_Output is the output of a sh.tangled.repo.listSecrets call.
18
+
type RepoListSecrets_Output struct {
19
+
Secrets []*RepoListSecrets_Secret `json:"secrets" cborgen:"secrets"`
20
+
}
21
+
22
+
// RepoListSecrets_Secret is a "secret" in the sh.tangled.repo.listSecrets schema.
23
+
type RepoListSecrets_Secret struct {
24
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
25
+
CreatedBy string `json:"createdBy" cborgen:"createdBy"`
26
+
Key string `json:"key" cborgen:"key"`
27
+
Repo string `json:"repo" cborgen:"repo"`
28
+
}
29
+
30
+
// RepoListSecrets calls the XRPC method "sh.tangled.repo.listSecrets".
31
+
func RepoListSecrets(ctx context.Context, c util.LexClient, repo string) (*RepoListSecrets_Output, error) {
32
+
var out RepoListSecrets_Output
33
+
34
+
params := map[string]interface{}{}
35
+
params["repo"] = repo
36
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.listSecrets", params, nil, &out); err != nil {
37
+
return nil, err
38
+
}
39
+
40
+
return &out, nil
41
+
}
+30
api/tangled/reporemoveSecret.go
+30
api/tangled/reporemoveSecret.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.removeSecret
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoRemoveSecretNSID = "sh.tangled.repo.removeSecret"
15
+
)
16
+
17
+
// RepoRemoveSecret_Input is the input argument to a sh.tangled.repo.removeSecret call.
18
+
type RepoRemoveSecret_Input struct {
19
+
Key string `json:"key" cborgen:"key"`
20
+
Repo string `json:"repo" cborgen:"repo"`
21
+
}
22
+
23
+
// RepoRemoveSecret calls the XRPC method "sh.tangled.repo.removeSecret".
24
+
func RepoRemoveSecret(ctx context.Context, c util.LexClient, input *RepoRemoveSecret_Input) error {
25
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.removeSecret", nil, input, nil); err != nil {
26
+
return err
27
+
}
28
+
29
+
return nil
30
+
}
+30
api/tangled/reposetDefaultBranch.go
+30
api/tangled/reposetDefaultBranch.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.setDefaultBranch
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoSetDefaultBranchNSID = "sh.tangled.repo.setDefaultBranch"
15
+
)
16
+
17
+
// RepoSetDefaultBranch_Input is the input argument to a sh.tangled.repo.setDefaultBranch call.
18
+
type RepoSetDefaultBranch_Input struct {
19
+
DefaultBranch string `json:"defaultBranch" cborgen:"defaultBranch"`
20
+
Repo string `json:"repo" cborgen:"repo"`
21
+
}
22
+
23
+
// RepoSetDefaultBranch calls the XRPC method "sh.tangled.repo.setDefaultBranch".
24
+
func RepoSetDefaultBranch(ctx context.Context, c util.LexClient, input *RepoSetDefaultBranch_Input) error {
25
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.setDefaultBranch", nil, input, nil); err != nil {
26
+
return err
27
+
}
28
+
29
+
return nil
30
+
}
+3
-1
api/tangled/stateclosed.go
+3
-1
api/tangled/stateclosed.go
+3
-1
api/tangled/stateopen.go
+3
-1
api/tangled/stateopen.go
+3
-1
api/tangled/statusclosed.go
+3
-1
api/tangled/statusclosed.go
+3
-1
api/tangled/statusmerged.go
+3
-1
api/tangled/statusmerged.go
+3
-1
api/tangled/statusopen.go
+3
-1
api/tangled/statusopen.go
+25
api/tangled/tangledstring.go
+25
api/tangled/tangledstring.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.string
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
StringNSID = "sh.tangled.string"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.string", &String{})
17
+
} //
18
+
// RECORDTYPE: String
19
+
type String struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"`
21
+
Contents string `json:"contents" cborgen:"contents"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Description string `json:"description" cborgen:"description"`
24
+
Filename string `json:"filename" cborgen:"filename"`
25
+
}
+18
-5
appview/config/config.go
+18
-5
appview/config/config.go
···
10
)
11
12
type CoreConfig struct {
13
-
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
14
-
DbPath string `env:"DB_PATH, default=appview.db"`
15
-
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
16
-
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
17
-
Dev bool `env:"DEV, default=false"`
18
}
19
20
type OAuthConfig struct {
···
59
DB int `env:"DB, default=0"`
60
}
61
62
func (cfg RedisConfig) ToURL() string {
63
u := &url.URL{
64
Scheme: "redis",
···
84
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
85
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
86
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
87
}
88
89
func LoadConfig(ctx context.Context) (*Config, error) {
···
10
)
11
12
type CoreConfig struct {
13
+
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
14
+
DbPath string `env:"DB_PATH, default=appview.db"`
15
+
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
16
+
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
17
+
Dev bool `env:"DEV, default=false"`
18
+
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
19
}
20
21
type OAuthConfig struct {
···
60
DB int `env:"DB, default=0"`
61
}
62
63
+
type PdsConfig struct {
64
+
Host string `env:"HOST, default=https://tngl.sh"`
65
+
AdminSecret string `env:"ADMIN_SECRET"`
66
+
}
67
+
68
+
type Cloudflare struct {
69
+
ApiToken string `env:"API_TOKEN"`
70
+
ZoneId string `env:"ZONE_ID"`
71
+
}
72
+
73
func (cfg RedisConfig) ToURL() string {
74
u := &url.URL{
75
Scheme: "redis",
···
95
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
96
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
97
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
98
+
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
99
+
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
100
}
101
102
func LoadConfig(ctx context.Context) (*Config, error) {
+76
appview/db/collaborators.go
+76
appview/db/collaborators.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
"time"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
)
10
+
11
+
type Collaborator struct {
12
+
// identifiers for the record
13
+
Id int64
14
+
Did syntax.DID
15
+
Rkey string
16
+
17
+
// content
18
+
SubjectDid syntax.DID
19
+
RepoAt syntax.ATURI
20
+
21
+
// meta
22
+
Created time.Time
23
+
}
24
+
25
+
func AddCollaborator(e Execer, c Collaborator) error {
26
+
_, err := e.Exec(
27
+
`insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`,
28
+
c.Did, c.Rkey, c.SubjectDid, c.RepoAt,
29
+
)
30
+
return err
31
+
}
32
+
33
+
func DeleteCollaborator(e Execer, filters ...filter) error {
34
+
var conditions []string
35
+
var args []any
36
+
for _, filter := range filters {
37
+
conditions = append(conditions, filter.Condition())
38
+
args = append(args, filter.Arg()...)
39
+
}
40
+
41
+
whereClause := ""
42
+
if conditions != nil {
43
+
whereClause = " where " + strings.Join(conditions, " and ")
44
+
}
45
+
46
+
query := fmt.Sprintf(`delete from collaborators %s`, whereClause)
47
+
48
+
_, err := e.Exec(query, args...)
49
+
return err
50
+
}
51
+
52
+
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
53
+
rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator)
54
+
if err != nil {
55
+
return nil, err
56
+
}
57
+
defer rows.Close()
58
+
59
+
var repoAts []string
60
+
for rows.Next() {
61
+
var aturi string
62
+
err := rows.Scan(&aturi)
63
+
if err != nil {
64
+
return nil, err
65
+
}
66
+
repoAts = append(repoAts, aturi)
67
+
}
68
+
if err := rows.Err(); err != nil {
69
+
return nil, err
70
+
}
71
+
if repoAts == nil {
72
+
return nil, nil
73
+
}
74
+
75
+
return GetRepos(e, 0, FilterIn("at_uri", repoAts))
76
+
}
+80
-3
appview/db/db.go
+80
-3
appview/db/db.go
···
355
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
356
357
-- constraints
358
-
foreign key (did, instance) references spindles(owner, instance) on delete cascade,
359
unique (did, instance, subject)
360
);
361
···
435
bytes integer not null check (bytes >= 0),
436
437
unique(repo_at, ref, language)
438
);
439
440
create table if not exists migrations (
···
579
return nil
580
})
581
582
return &DB{db}, nil
583
}
584
···
654
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
655
if kind == reflect.Slice || kind == reflect.Array {
656
if rv.Len() == 0 {
657
-
panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key))
658
}
659
660
placeholders := make([]string, rv.Len())
···
673
kind := rv.Kind()
674
if kind == reflect.Slice || kind == reflect.Array {
675
if rv.Len() == 0 {
676
-
panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key))
677
}
678
679
out := make([]any, rv.Len())
···
355
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
356
357
-- constraints
358
unique (did, instance, subject)
359
);
360
···
434
bytes integer not null check (bytes >= 0),
435
436
unique(repo_at, ref, language)
437
+
);
438
+
439
+
create table if not exists signups_inflight (
440
+
id integer primary key autoincrement,
441
+
email text not null unique,
442
+
invite_code text not null,
443
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
444
+
);
445
+
446
+
create table if not exists strings (
447
+
-- identifiers
448
+
did text not null,
449
+
rkey text not null,
450
+
451
+
-- content
452
+
filename text not null,
453
+
description text,
454
+
content text not null,
455
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
456
+
edited text,
457
+
458
+
primary key (did, rkey)
459
);
460
461
create table if not exists migrations (
···
600
return nil
601
})
602
603
+
// recreate and add rkey + created columns with default constraint
604
+
runMigration(db, "rework-collaborators-table", func(tx *sql.Tx) error {
605
+
// create new table
606
+
// - repo_at instead of repo integer
607
+
// - rkey field
608
+
// - created field
609
+
_, err := tx.Exec(`
610
+
create table collaborators_new (
611
+
-- identifiers for the record
612
+
id integer primary key autoincrement,
613
+
did text not null,
614
+
rkey text,
615
+
616
+
-- content
617
+
subject_did text not null,
618
+
repo_at text not null,
619
+
620
+
-- meta
621
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
622
+
623
+
-- constraints
624
+
foreign key (repo_at) references repos(at_uri) on delete cascade
625
+
)
626
+
`)
627
+
if err != nil {
628
+
return err
629
+
}
630
+
631
+
// copy data
632
+
_, err = tx.Exec(`
633
+
insert into collaborators_new (id, did, rkey, subject_did, repo_at)
634
+
select
635
+
c.id,
636
+
r.did,
637
+
'',
638
+
c.did,
639
+
r.at_uri
640
+
from collaborators c
641
+
join repos r on c.repo = r.id
642
+
`)
643
+
if err != nil {
644
+
return err
645
+
}
646
+
647
+
// drop old table
648
+
_, err = tx.Exec(`drop table collaborators`)
649
+
if err != nil {
650
+
return err
651
+
}
652
+
653
+
// rename new table
654
+
_, err = tx.Exec(`alter table collaborators_new rename to collaborators`)
655
+
return err
656
+
})
657
+
658
return &DB{db}, nil
659
}
660
···
730
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
731
if kind == reflect.Slice || kind == reflect.Array {
732
if rv.Len() == 0 {
733
+
// always false
734
+
return "1 = 0"
735
}
736
737
placeholders := make([]string, rv.Len())
···
750
kind := rv.Kind()
751
if kind == reflect.Slice || kind == reflect.Array {
752
if rv.Len() == 0 {
753
+
return nil
754
}
755
756
out := make([]any, rv.Len())
+16
-2
appview/db/email.go
+16
-2
appview/db/email.go
···
103
query := `
104
select email, did
105
from emails
106
+
where
107
+
verified = ?
108
and email in (` + strings.Join(placeholders, ",") + `)
109
`
110
···
153
`
154
var count int
155
err := e.QueryRow(query, did, email).Scan(&count)
156
+
if err != nil {
157
+
return false, err
158
+
}
159
+
return count > 0, nil
160
+
}
161
+
162
+
func CheckEmailExistsAtAll(e Execer, email string) (bool, error) {
163
+
query := `
164
+
select count(*)
165
+
from emails
166
+
where email = ?
167
+
`
168
+
var count int
169
+
err := e.QueryRow(query, email).Scan(&count)
170
if err != nil {
171
return false, err
172
}
+2
-2
appview/db/follow.go
+2
-2
appview/db/follow.go
+17
-12
appview/db/issues.go
+17
-12
appview/db/issues.go
···
9
)
10
11
type Issue struct {
12
RepoAt syntax.ATURI
13
OwnerDid string
14
IssueId int
···
65
66
issue.IssueId = nextId
67
68
-
_, err = tx.Exec(`
69
insert into issues (repo_at, owner_did, issue_id, title, body)
70
values (?, ?, ?, ?, ?)
71
`, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body)
72
if err != nil {
73
return err
74
}
75
76
if err := tx.Commit(); err != nil {
77
return err
···
89
var issueAt string
90
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
91
return issueAt, err
92
-
}
93
-
94
-
func GetIssueId(e Execer, repoAt syntax.ATURI) (int, error) {
95
-
var issueId int
96
-
err := e.QueryRow(`select next_issue_id from repo_issue_seqs where repo_at = ?`, repoAt).Scan(&issueId)
97
-
return issueId - 1, err
98
}
99
100
func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
···
114
`
115
with numbered_issue as (
116
select
117
i.owner_did,
118
i.issue_id,
119
i.created,
···
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,
···
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
}
···
182
183
rows, err := e.Query(
184
`select
185
i.owner_did,
186
i.repo_at,
187
i.issue_id,
···
213
var issueCreatedAt, repoCreatedAt string
214
var repo Repo
215
err := rows.Scan(
216
&issue.OwnerDid,
217
&issue.RepoAt,
218
&issue.IssueId,
···
257
}
258
259
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
260
-
query := `select owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?`
261
row := e.QueryRow(query, repoAt, issueId)
262
263
var issue Issue
264
var createdAt string
265
-
err := row.Scan(&issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open)
266
if err != nil {
267
return nil, err
268
}
···
277
}
278
279
func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
280
-
query := `select owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?`
281
row := e.QueryRow(query, repoAt, issueId)
282
283
var issue Issue
284
var createdAt string
285
-
err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt)
286
if err != nil {
287
return nil, nil, err
288
}
···
9
)
10
11
type Issue struct {
12
+
ID int64
13
RepoAt syntax.ATURI
14
OwnerDid string
15
IssueId int
···
66
67
issue.IssueId = nextId
68
69
+
res, err := tx.Exec(`
70
insert into issues (repo_at, owner_did, issue_id, title, body)
71
values (?, ?, ?, ?, ?)
72
`, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body)
73
if err != nil {
74
return err
75
}
76
+
77
+
lastID, err := res.LastInsertId()
78
+
if err != nil {
79
+
return err
80
+
}
81
+
issue.ID = lastID
82
83
if err := tx.Commit(); err != nil {
84
return err
···
96
var issueAt string
97
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
98
return issueAt, err
99
}
100
101
func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
···
115
`
116
with numbered_issue as (
117
select
118
+
i.id,
119
i.owner_did,
120
i.issue_id,
121
i.created,
···
134
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
135
)
136
select
137
+
id,
138
owner_did,
139
issue_id,
140
created,
···
156
var issue Issue
157
var createdAt string
158
var metadata IssueMetadata
159
+
err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
160
if err != nil {
161
return nil, err
162
}
···
185
186
rows, err := e.Query(
187
`select
188
+
i.id,
189
i.owner_did,
190
i.repo_at,
191
i.issue_id,
···
217
var issueCreatedAt, repoCreatedAt string
218
var repo Repo
219
err := rows.Scan(
220
+
&issue.ID,
221
&issue.OwnerDid,
222
&issue.RepoAt,
223
&issue.IssueId,
···
262
}
263
264
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
265
+
query := `select id, owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?`
266
row := e.QueryRow(query, repoAt, issueId)
267
268
var issue Issue
269
var createdAt string
270
+
err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open)
271
if err != nil {
272
return nil, err
273
}
···
282
}
283
284
func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
285
+
query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?`
286
row := e.QueryRow(query, repoAt, issueId)
287
288
var issue Issue
289
var createdAt string
290
+
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt)
291
if err != nil {
292
return nil, nil, err
293
}
-34
appview/db/repos.go
-34
appview/db/repos.go
···
550
return &repo, nil
551
}
552
553
-
func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error {
554
-
_, err := e.Exec(
555
-
`insert into collaborators (did, repo)
556
-
values (?, (select id from repos where did = ? and name = ? and knot = ?));`,
557
-
collaborator, repoOwnerDid, repoName, repoKnot)
558
-
return err
559
-
}
560
-
561
func UpdateDescription(e Execer, repoAt, newDescription string) error {
562
_, err := e.Exec(
563
`update repos set description = ? where at_uri = ?`, newDescription, repoAt)
···
568
_, err := e.Exec(
569
`update repos set spindle = ? where at_uri = ?`, spindle, repoAt)
570
return err
571
-
}
572
-
573
-
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
574
-
rows, err := e.Query(`select repo from collaborators where did = ?`, collaborator)
575
-
if err != nil {
576
-
return nil, err
577
-
}
578
-
defer rows.Close()
579
-
580
-
var repoIds []int
581
-
for rows.Next() {
582
-
var id int
583
-
err := rows.Scan(&id)
584
-
if err != nil {
585
-
return nil, err
586
-
}
587
-
repoIds = append(repoIds, id)
588
-
}
589
-
if err := rows.Err(); err != nil {
590
-
return nil, err
591
-
}
592
-
if repoIds == nil {
593
-
return nil, nil
594
-
}
595
-
596
-
return GetRepos(e, 0, FilterIn("id", repoIds))
597
}
598
599
type RepoStats struct {
···
550
return &repo, nil
551
}
552
553
func UpdateDescription(e Execer, repoAt, newDescription string) error {
554
_, err := e.Exec(
555
`update repos set description = ? where at_uri = ?`, newDescription, repoAt)
···
560
_, err := e.Exec(
561
`update repos set spindle = ? where at_uri = ?`, spindle, repoAt)
562
return err
563
}
564
565
type RepoStats struct {
+29
appview/db/signup.go
+29
appview/db/signup.go
···
···
1
+
package db
2
+
3
+
import "time"
4
+
5
+
type InflightSignup struct {
6
+
Id int64
7
+
Email string
8
+
InviteCode string
9
+
Created time.Time
10
+
}
11
+
12
+
func AddInflightSignup(e Execer, signup InflightSignup) error {
13
+
query := `insert into signups_inflight (email, invite_code) values (?, ?)`
14
+
_, err := e.Exec(query, signup.Email, signup.InviteCode)
15
+
return err
16
+
}
17
+
18
+
func DeleteInflightSignup(e Execer, email string) error {
19
+
query := `delete from signups_inflight where email = ?`
20
+
_, err := e.Exec(query, email)
21
+
return err
22
+
}
23
+
24
+
func GetEmailForCode(e Execer, inviteCode string) (string, error) {
25
+
query := `select email from signups_inflight where invite_code = ?`
26
+
var email string
27
+
err := e.QueryRow(query, inviteCode).Scan(&email)
28
+
return email, err
29
+
}
+7
-2
appview/db/star.go
+7
-2
appview/db/star.go
+251
appview/db/strings.go
+251
appview/db/strings.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"bytes"
5
+
"database/sql"
6
+
"errors"
7
+
"fmt"
8
+
"io"
9
+
"strings"
10
+
"time"
11
+
"unicode/utf8"
12
+
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
"tangled.sh/tangled.sh/core/api/tangled"
15
+
)
16
+
17
+
type String struct {
18
+
Did syntax.DID
19
+
Rkey string
20
+
21
+
Filename string
22
+
Description string
23
+
Contents string
24
+
Created time.Time
25
+
Edited *time.Time
26
+
}
27
+
28
+
func (s *String) StringAt() syntax.ATURI {
29
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
30
+
}
31
+
32
+
type StringStats struct {
33
+
LineCount uint64
34
+
ByteCount uint64
35
+
}
36
+
37
+
func (s String) Stats() StringStats {
38
+
lineCount, err := countLines(strings.NewReader(s.Contents))
39
+
if err != nil {
40
+
// non-fatal
41
+
// TODO: log this?
42
+
}
43
+
44
+
return StringStats{
45
+
LineCount: uint64(lineCount),
46
+
ByteCount: uint64(len(s.Contents)),
47
+
}
48
+
}
49
+
50
+
func (s String) Validate() error {
51
+
var err error
52
+
53
+
if !strings.Contains(s.Filename, ".") {
54
+
err = errors.Join(err, fmt.Errorf("missing filename extension"))
55
+
}
56
+
57
+
if strings.HasSuffix(s.Filename, ".") {
58
+
err = errors.Join(err, fmt.Errorf("filename ends with `.`"))
59
+
}
60
+
61
+
if utf8.RuneCountInString(s.Filename) > 140 {
62
+
err = errors.Join(err, fmt.Errorf("filename too long"))
63
+
}
64
+
65
+
if utf8.RuneCountInString(s.Description) > 280 {
66
+
err = errors.Join(err, fmt.Errorf("description too long"))
67
+
}
68
+
69
+
if len(s.Contents) == 0 {
70
+
err = errors.Join(err, fmt.Errorf("contents is empty"))
71
+
}
72
+
73
+
return err
74
+
}
75
+
76
+
func (s *String) AsRecord() tangled.String {
77
+
return tangled.String{
78
+
Filename: s.Filename,
79
+
Description: s.Description,
80
+
Contents: s.Contents,
81
+
CreatedAt: s.Created.Format(time.RFC3339),
82
+
}
83
+
}
84
+
85
+
func StringFromRecord(did, rkey string, record tangled.String) String {
86
+
created, err := time.Parse(record.CreatedAt, time.RFC3339)
87
+
if err != nil {
88
+
created = time.Now()
89
+
}
90
+
return String{
91
+
Did: syntax.DID(did),
92
+
Rkey: rkey,
93
+
Filename: record.Filename,
94
+
Description: record.Description,
95
+
Contents: record.Contents,
96
+
Created: created,
97
+
}
98
+
}
99
+
100
+
func AddString(e Execer, s String) error {
101
+
_, err := e.Exec(
102
+
`insert into strings (
103
+
did,
104
+
rkey,
105
+
filename,
106
+
description,
107
+
content,
108
+
created,
109
+
edited
110
+
)
111
+
values (?, ?, ?, ?, ?, ?, null)
112
+
on conflict(did, rkey) do update set
113
+
filename = excluded.filename,
114
+
description = excluded.description,
115
+
content = excluded.content,
116
+
edited = case
117
+
when
118
+
strings.content != excluded.content
119
+
or strings.filename != excluded.filename
120
+
or strings.description != excluded.description then ?
121
+
else strings.edited
122
+
end`,
123
+
s.Did,
124
+
s.Rkey,
125
+
s.Filename,
126
+
s.Description,
127
+
s.Contents,
128
+
s.Created.Format(time.RFC3339),
129
+
time.Now().Format(time.RFC3339),
130
+
)
131
+
return err
132
+
}
133
+
134
+
func GetStrings(e Execer, filters ...filter) ([]String, error) {
135
+
var all []String
136
+
137
+
var conditions []string
138
+
var args []any
139
+
for _, filter := range filters {
140
+
conditions = append(conditions, filter.Condition())
141
+
args = append(args, filter.Arg()...)
142
+
}
143
+
144
+
whereClause := ""
145
+
if conditions != nil {
146
+
whereClause = " where " + strings.Join(conditions, " and ")
147
+
}
148
+
149
+
query := fmt.Sprintf(`select
150
+
did,
151
+
rkey,
152
+
filename,
153
+
description,
154
+
content,
155
+
created,
156
+
edited
157
+
from strings %s`,
158
+
whereClause,
159
+
)
160
+
161
+
rows, err := e.Query(query, args...)
162
+
163
+
if err != nil {
164
+
return nil, err
165
+
}
166
+
defer rows.Close()
167
+
168
+
for rows.Next() {
169
+
var s String
170
+
var createdAt string
171
+
var editedAt sql.NullString
172
+
173
+
if err := rows.Scan(
174
+
&s.Did,
175
+
&s.Rkey,
176
+
&s.Filename,
177
+
&s.Description,
178
+
&s.Contents,
179
+
&createdAt,
180
+
&editedAt,
181
+
); err != nil {
182
+
return nil, err
183
+
}
184
+
185
+
s.Created, err = time.Parse(time.RFC3339, createdAt)
186
+
if err != nil {
187
+
s.Created = time.Now()
188
+
}
189
+
190
+
if editedAt.Valid {
191
+
e, err := time.Parse(time.RFC3339, editedAt.String)
192
+
if err != nil {
193
+
e = time.Now()
194
+
}
195
+
s.Edited = &e
196
+
}
197
+
198
+
all = append(all, s)
199
+
}
200
+
201
+
if err := rows.Err(); err != nil {
202
+
return nil, err
203
+
}
204
+
205
+
return all, nil
206
+
}
207
+
208
+
func DeleteString(e Execer, filters ...filter) error {
209
+
var conditions []string
210
+
var args []any
211
+
for _, filter := range filters {
212
+
conditions = append(conditions, filter.Condition())
213
+
args = append(args, filter.Arg()...)
214
+
}
215
+
216
+
whereClause := ""
217
+
if conditions != nil {
218
+
whereClause = " where " + strings.Join(conditions, " and ")
219
+
}
220
+
221
+
query := fmt.Sprintf(`delete from strings %s`, whereClause)
222
+
223
+
_, err := e.Exec(query, args...)
224
+
return err
225
+
}
226
+
227
+
func countLines(r io.Reader) (int, error) {
228
+
buf := make([]byte, 32*1024)
229
+
bufLen := 0
230
+
count := 0
231
+
nl := []byte{'\n'}
232
+
233
+
for {
234
+
c, err := r.Read(buf)
235
+
if c > 0 {
236
+
bufLen += c
237
+
}
238
+
count += bytes.Count(buf[:c], nl)
239
+
240
+
switch {
241
+
case err == io.EOF:
242
+
/* handle last line not having a newline at the end */
243
+
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
244
+
count++
245
+
}
246
+
return count, nil
247
+
case err != nil:
248
+
return 0, err
249
+
}
250
+
}
251
+
}
+53
appview/dns/cloudflare.go
+53
appview/dns/cloudflare.go
···
···
1
+
package dns
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
7
+
"github.com/cloudflare/cloudflare-go"
8
+
"tangled.sh/tangled.sh/core/appview/config"
9
+
)
10
+
11
+
type Record struct {
12
+
Type string
13
+
Name string
14
+
Content string
15
+
TTL int
16
+
Proxied bool
17
+
}
18
+
19
+
type Cloudflare struct {
20
+
api *cloudflare.API
21
+
zone string
22
+
}
23
+
24
+
func NewCloudflare(c *config.Config) (*Cloudflare, error) {
25
+
apiToken := c.Cloudflare.ApiToken
26
+
api, err := cloudflare.NewWithAPIToken(apiToken)
27
+
if err != nil {
28
+
return nil, err
29
+
}
30
+
return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil
31
+
}
32
+
33
+
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error {
34
+
_, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
35
+
Type: record.Type,
36
+
Name: record.Name,
37
+
Content: record.Content,
38
+
TTL: record.TTL,
39
+
Proxied: &record.Proxied,
40
+
})
41
+
if err != nil {
42
+
return fmt.Errorf("failed to create DNS record: %w", err)
43
+
}
44
+
return nil
45
+
}
46
+
47
+
func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
48
+
err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID)
49
+
if err != nil {
50
+
return fmt.Errorf("failed to delete DNS record: %w", err)
51
+
}
52
+
return nil
53
+
}
-113
appview/idresolver/resolver.go
-113
appview/idresolver/resolver.go
···
1
-
package idresolver
2
-
3
-
import (
4
-
"context"
5
-
"net"
6
-
"net/http"
7
-
"sync"
8
-
"time"
9
-
10
-
"github.com/bluesky-social/indigo/atproto/identity"
11
-
"github.com/bluesky-social/indigo/atproto/identity/redisdir"
12
-
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"github.com/carlmjohnson/versioninfo"
14
-
"tangled.sh/tangled.sh/core/appview/config"
15
-
)
16
-
17
-
type Resolver struct {
18
-
directory identity.Directory
19
-
}
20
-
21
-
func BaseDirectory() identity.Directory {
22
-
base := identity.BaseDirectory{
23
-
PLCURL: identity.DefaultPLCURL,
24
-
HTTPClient: http.Client{
25
-
Timeout: time.Second * 10,
26
-
Transport: &http.Transport{
27
-
// would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad.
28
-
IdleConnTimeout: time.Millisecond * 1000,
29
-
MaxIdleConns: 100,
30
-
},
31
-
},
32
-
Resolver: net.Resolver{
33
-
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
34
-
d := net.Dialer{Timeout: time.Second * 3}
35
-
return d.DialContext(ctx, network, address)
36
-
},
37
-
},
38
-
TryAuthoritativeDNS: true,
39
-
// primary Bluesky PDS instance only supports HTTP resolution method
40
-
SkipDNSDomainSuffixes: []string{".bsky.social"},
41
-
UserAgent: "indigo-identity/" + versioninfo.Short(),
42
-
}
43
-
return &base
44
-
}
45
-
46
-
func RedisDirectory(url string) (identity.Directory, error) {
47
-
hitTTL := time.Hour * 24
48
-
errTTL := time.Second * 30
49
-
invalidHandleTTL := time.Minute * 5
50
-
return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
51
-
}
52
-
53
-
func DefaultResolver() *Resolver {
54
-
return &Resolver{
55
-
directory: identity.DefaultDirectory(),
56
-
}
57
-
}
58
-
59
-
func RedisResolver(config config.RedisConfig) (*Resolver, error) {
60
-
directory, err := RedisDirectory(config.ToURL())
61
-
if err != nil {
62
-
return nil, err
63
-
}
64
-
return &Resolver{
65
-
directory: directory,
66
-
}, nil
67
-
}
68
-
69
-
func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) {
70
-
id, err := syntax.ParseAtIdentifier(arg)
71
-
if err != nil {
72
-
return nil, err
73
-
}
74
-
75
-
return r.directory.Lookup(ctx, *id)
76
-
}
77
-
78
-
func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity {
79
-
results := make([]*identity.Identity, len(idents))
80
-
var wg sync.WaitGroup
81
-
82
-
done := make(chan struct{})
83
-
defer close(done)
84
-
85
-
for idx, ident := range idents {
86
-
wg.Add(1)
87
-
go func(index int, id string) {
88
-
defer wg.Done()
89
-
90
-
select {
91
-
case <-ctx.Done():
92
-
results[index] = nil
93
-
case <-done:
94
-
results[index] = nil
95
-
default:
96
-
identity, _ := r.ResolveIdent(ctx, id)
97
-
results[index] = identity
98
-
}
99
-
}(idx, ident)
100
-
}
101
-
102
-
wg.Wait()
103
-
return results
104
-
}
105
-
106
-
func (r *Resolver) InvalidateIdent(ctx context.Context, arg string) error {
107
-
id, err := syntax.ParseAtIdentifier(arg)
108
-
if err != nil {
109
-
return err
110
-
}
111
-
112
-
return r.directory.Purge(ctx, *id)
113
-
}
···
+77
-4
appview/ingester.go
+77
-4
appview/ingester.go
···
14
"tangled.sh/tangled.sh/core/api/tangled"
15
"tangled.sh/tangled.sh/core/appview/config"
16
"tangled.sh/tangled.sh/core/appview/db"
17
-
"tangled.sh/tangled.sh/core/appview/idresolver"
18
"tangled.sh/tangled.sh/core/appview/spindleverify"
19
"tangled.sh/tangled.sh/core/rbac"
20
)
21
···
64
err = i.ingestSpindleMember(e)
65
case tangled.SpindleNSID:
66
err = i.ingestSpindle(e)
67
}
68
l = i.Logger.With("nsid", e.Commit.Collection)
69
}
···
100
l.Error("invalid record", "err", err)
101
return err
102
}
103
-
err = db.AddStar(i.Db, did, subjectUri, e.Commit.RKey)
104
case models.CommitOperationDelete:
105
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
106
}
···
129
return err
130
}
131
132
-
subjectDid := record.Subject
133
-
err = db.AddFollow(i.Db, did, subjectDid, e.Commit.RKey)
134
case models.CommitOperationDelete:
135
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
136
}
···
503
i.Enforcer.E.LoadPolicy()
504
}()
505
506
err = db.DeleteSpindle(
507
tx,
508
db.FilterEq("owner", did),
···
532
533
return nil
534
}
···
14
"tangled.sh/tangled.sh/core/api/tangled"
15
"tangled.sh/tangled.sh/core/appview/config"
16
"tangled.sh/tangled.sh/core/appview/db"
17
"tangled.sh/tangled.sh/core/appview/spindleverify"
18
+
"tangled.sh/tangled.sh/core/idresolver"
19
"tangled.sh/tangled.sh/core/rbac"
20
)
21
···
64
err = i.ingestSpindleMember(e)
65
case tangled.SpindleNSID:
66
err = i.ingestSpindle(e)
67
+
case tangled.StringNSID:
68
+
err = i.ingestString(e)
69
}
70
l = i.Logger.With("nsid", e.Commit.Collection)
71
}
···
102
l.Error("invalid record", "err", err)
103
return err
104
}
105
+
err = db.AddStar(i.Db, &db.Star{
106
+
StarredByDid: did,
107
+
RepoAt: subjectUri,
108
+
Rkey: e.Commit.RKey,
109
+
})
110
case models.CommitOperationDelete:
111
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
112
}
···
135
return err
136
}
137
138
+
err = db.AddFollow(i.Db, &db.Follow{
139
+
UserDid: did,
140
+
SubjectDid: record.Subject,
141
+
Rkey: e.Commit.RKey,
142
+
})
143
case models.CommitOperationDelete:
144
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
145
}
···
512
i.Enforcer.E.LoadPolicy()
513
}()
514
515
+
// remove spindle members first
516
+
err = db.RemoveSpindleMember(
517
+
tx,
518
+
db.FilterEq("owner", did),
519
+
db.FilterEq("instance", instance),
520
+
)
521
+
if err != nil {
522
+
return err
523
+
}
524
+
525
err = db.DeleteSpindle(
526
tx,
527
db.FilterEq("owner", did),
···
551
552
return nil
553
}
554
+
555
+
func (i *Ingester) ingestString(e *models.Event) error {
556
+
did := e.Did
557
+
rkey := e.Commit.RKey
558
+
559
+
var err error
560
+
561
+
l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
562
+
l.Info("ingesting record")
563
+
564
+
ddb, ok := i.Db.Execer.(*db.DB)
565
+
if !ok {
566
+
return fmt.Errorf("failed to index string record, invalid db cast")
567
+
}
568
+
569
+
switch e.Commit.Operation {
570
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
571
+
raw := json.RawMessage(e.Commit.Record)
572
+
record := tangled.String{}
573
+
err = json.Unmarshal(raw, &record)
574
+
if err != nil {
575
+
l.Error("invalid record", "err", err)
576
+
return err
577
+
}
578
+
579
+
string := db.StringFromRecord(did, rkey, record)
580
+
581
+
if err = string.Validate(); err != nil {
582
+
l.Error("invalid record", "err", err)
583
+
return err
584
+
}
585
+
586
+
if err = db.AddString(ddb, string); err != nil {
587
+
l.Error("failed to add string", "err", err)
588
+
return err
589
+
}
590
+
591
+
return nil
592
+
593
+
case models.CommitOperationDelete:
594
+
if err := db.DeleteString(
595
+
ddb,
596
+
db.FilterEq("did", did),
597
+
db.FilterEq("rkey", rkey),
598
+
); err != nil {
599
+
l.Error("failed to delete", "err", err)
600
+
return fmt.Errorf("failed to delete string record: %w", err)
601
+
}
602
+
603
+
return nil
604
+
}
605
+
606
+
return nil
607
+
}
+18
-33
appview/issues/issues.go
+18
-33
appview/issues/issues.go
···
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
lexutil "github.com/bluesky-social/indigo/lex/util"
16
"github.com/go-chi/chi/v5"
17
-
"github.com/posthog/posthog-go"
18
19
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview"
21
"tangled.sh/tangled.sh/core/appview/config"
22
"tangled.sh/tangled.sh/core/appview/db"
23
-
"tangled.sh/tangled.sh/core/appview/idresolver"
24
"tangled.sh/tangled.sh/core/appview/oauth"
25
"tangled.sh/tangled.sh/core/appview/pages"
26
"tangled.sh/tangled.sh/core/appview/pagination"
27
"tangled.sh/tangled.sh/core/appview/reporesolver"
28
)
29
30
type Issues struct {
···
34
idResolver *idresolver.Resolver
35
db *db.DB
36
config *config.Config
37
-
posthog posthog.Client
38
}
39
40
func New(
···
44
idResolver *idresolver.Resolver,
45
db *db.DB,
46
config *config.Config,
47
-
posthog posthog.Client,
48
) *Issues {
49
return &Issues{
50
oauth: oauth,
···
53
idResolver: idResolver,
54
db: db,
55
config: config,
56
-
posthog: posthog,
57
}
58
}
59
···
120
DidHandleMap: didHandleMap,
121
122
OrderedReactionKinds: db.OrderedReactionKinds,
123
-
Reactions: reactionCountMap,
124
-
UserReacted: userReactions,
125
})
126
127
}
···
171
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
172
Collection: tangled.RepoIssueStateNSID,
173
Repo: user.Did,
174
-
Rkey: appview.TID(),
175
Record: &lexutil.LexiconTypeDecoder{
176
Val: &tangled.RepoIssueState{
177
Issue: issue.IssueAt,
···
275
}
276
277
commentId := mathrand.IntN(1000000)
278
-
rkey := appview.TID()
279
280
err := db.NewIssueComment(rp.db, &db.Comment{
281
OwnerDid: user.Did,
···
703
return
704
}
705
706
-
err = db.NewIssue(tx, &db.Issue{
707
RepoAt: f.RepoAt,
708
Title: title,
709
Body: body,
710
OwnerDid: user.Did,
711
-
})
712
-
if err != nil {
713
-
log.Println("failed to create issue", err)
714
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
715
-
return
716
}
717
-
718
-
issueId, err := db.GetIssueId(rp.db, f.RepoAt)
719
if err != nil {
720
-
log.Println("failed to get issue id", err)
721
rp.pages.Notice(w, "issues", "Failed to create issue.")
722
return
723
}
···
732
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
733
Collection: tangled.RepoIssueNSID,
734
Repo: user.Did,
735
-
Rkey: appview.TID(),
736
Record: &lexutil.LexiconTypeDecoder{
737
Val: &tangled.RepoIssue{
738
Repo: atUri,
739
Title: title,
740
Body: &body,
741
Owner: user.Did,
742
-
IssueId: int64(issueId),
743
},
744
},
745
})
···
749
return
750
}
751
752
-
err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri)
753
if err != nil {
754
log.Println("failed to set issue at", err)
755
rp.pages.Notice(w, "issues", "Failed to create issue.")
756
return
757
}
758
759
-
if !rp.config.Core.Dev {
760
-
err = rp.posthog.Enqueue(posthog.Capture{
761
-
DistinctId: user.Did,
762
-
Event: "new_issue",
763
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId},
764
-
})
765
-
if err != nil {
766
-
log.Println("failed to enqueue posthog event:", err)
767
-
}
768
-
}
769
770
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
771
return
772
}
773
}
···
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
lexutil "github.com/bluesky-social/indigo/lex/util"
16
"github.com/go-chi/chi/v5"
17
18
"tangled.sh/tangled.sh/core/api/tangled"
19
"tangled.sh/tangled.sh/core/appview/config"
20
"tangled.sh/tangled.sh/core/appview/db"
21
+
"tangled.sh/tangled.sh/core/appview/notify"
22
"tangled.sh/tangled.sh/core/appview/oauth"
23
"tangled.sh/tangled.sh/core/appview/pages"
24
"tangled.sh/tangled.sh/core/appview/pagination"
25
"tangled.sh/tangled.sh/core/appview/reporesolver"
26
+
"tangled.sh/tangled.sh/core/idresolver"
27
+
"tangled.sh/tangled.sh/core/tid"
28
)
29
30
type Issues struct {
···
34
idResolver *idresolver.Resolver
35
db *db.DB
36
config *config.Config
37
+
notifier notify.Notifier
38
}
39
40
func New(
···
44
idResolver *idresolver.Resolver,
45
db *db.DB,
46
config *config.Config,
47
+
notifier notify.Notifier,
48
) *Issues {
49
return &Issues{
50
oauth: oauth,
···
53
idResolver: idResolver,
54
db: db,
55
config: config,
56
+
notifier: notifier,
57
}
58
}
59
···
120
DidHandleMap: didHandleMap,
121
122
OrderedReactionKinds: db.OrderedReactionKinds,
123
+
Reactions: reactionCountMap,
124
+
UserReacted: userReactions,
125
})
126
127
}
···
171
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
172
Collection: tangled.RepoIssueStateNSID,
173
Repo: user.Did,
174
+
Rkey: tid.TID(),
175
Record: &lexutil.LexiconTypeDecoder{
176
Val: &tangled.RepoIssueState{
177
Issue: issue.IssueAt,
···
275
}
276
277
commentId := mathrand.IntN(1000000)
278
+
rkey := tid.TID()
279
280
err := db.NewIssueComment(rp.db, &db.Comment{
281
OwnerDid: user.Did,
···
703
return
704
}
705
706
+
issue := &db.Issue{
707
RepoAt: f.RepoAt,
708
Title: title,
709
Body: body,
710
OwnerDid: user.Did,
711
}
712
+
err = db.NewIssue(tx, issue)
713
if err != nil {
714
+
log.Println("failed to create issue", err)
715
rp.pages.Notice(w, "issues", "Failed to create issue.")
716
return
717
}
···
726
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
727
Collection: tangled.RepoIssueNSID,
728
Repo: user.Did,
729
+
Rkey: tid.TID(),
730
Record: &lexutil.LexiconTypeDecoder{
731
Val: &tangled.RepoIssue{
732
Repo: atUri,
733
Title: title,
734
Body: &body,
735
Owner: user.Did,
736
+
IssueId: int64(issue.IssueId),
737
},
738
},
739
})
···
743
return
744
}
745
746
+
err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri)
747
if err != nil {
748
log.Println("failed to set issue at", err)
749
rp.pages.Notice(w, "issues", "Failed to create issue.")
750
return
751
}
752
753
+
rp.notifier.NewIssue(r.Context(), issue)
754
755
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
756
return
757
}
758
}
+3
-4
appview/knots/knots.go
+3
-4
appview/knots/knots.go
···
13
14
"github.com/go-chi/chi/v5"
15
"tangled.sh/tangled.sh/core/api/tangled"
16
-
"tangled.sh/tangled.sh/core/appview"
17
"tangled.sh/tangled.sh/core/appview/config"
18
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/idresolver"
20
"tangled.sh/tangled.sh/core/appview/middleware"
21
"tangled.sh/tangled.sh/core/appview/oauth"
22
"tangled.sh/tangled.sh/core/appview/pages"
23
"tangled.sh/tangled.sh/core/eventconsumer"
24
"tangled.sh/tangled.sh/core/knotclient"
25
"tangled.sh/tangled.sh/core/rbac"
26
27
comatproto "github.com/bluesky-social/indigo/api/atproto"
28
lexutil "github.com/bluesky-social/indigo/lex/util"
···
378
}
379
380
w.Write([]byte(strings.Join(memberDids, "\n")))
381
-
return
382
}
383
384
// add member to domain, requires auth and requires invite access
···
436
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
437
Collection: tangled.KnotMemberNSID,
438
Repo: currentUser.Did,
439
-
Rkey: appview.TID(),
440
Record: &lexutil.LexiconTypeDecoder{
441
Val: &tangled.KnotMember{
442
Subject: subjectIdentity.DID.String(),
···
13
14
"github.com/go-chi/chi/v5"
15
"tangled.sh/tangled.sh/core/api/tangled"
16
"tangled.sh/tangled.sh/core/appview/config"
17
"tangled.sh/tangled.sh/core/appview/db"
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
"tangled.sh/tangled.sh/core/eventconsumer"
22
+
"tangled.sh/tangled.sh/core/idresolver"
23
"tangled.sh/tangled.sh/core/knotclient"
24
"tangled.sh/tangled.sh/core/rbac"
25
+
"tangled.sh/tangled.sh/core/tid"
26
27
comatproto "github.com/bluesky-social/indigo/api/atproto"
28
lexutil "github.com/bluesky-social/indigo/lex/util"
···
378
}
379
380
w.Write([]byte(strings.Join(memberDids, "\n")))
381
}
382
383
// add member to domain, requires auth and requires invite access
···
435
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
436
Collection: tangled.KnotMemberNSID,
437
Repo: currentUser.Did,
438
+
Rkey: tid.TID(),
439
Record: &lexutil.LexiconTypeDecoder{
440
Val: &tangled.KnotMember{
441
Subject: subjectIdentity.DID.String(),
+3
-11
appview/middleware/middleware.go
+3
-11
appview/middleware/middleware.go
···
13
"github.com/bluesky-social/indigo/atproto/identity"
14
"github.com/go-chi/chi/v5"
15
"tangled.sh/tangled.sh/core/appview/db"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
17
"tangled.sh/tangled.sh/core/appview/oauth"
18
"tangled.sh/tangled.sh/core/appview/pages"
19
"tangled.sh/tangled.sh/core/appview/pagination"
20
"tangled.sh/tangled.sh/core/appview/reporesolver"
21
"tangled.sh/tangled.sh/core/rbac"
22
)
23
···
167
}
168
}
169
170
-
func StripLeadingAt(next http.Handler) http.Handler {
171
-
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
172
-
path := req.URL.EscapedPath()
173
-
if strings.HasPrefix(path, "/@") {
174
-
req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@")
175
-
}
176
-
next.ServeHTTP(w, req)
177
-
})
178
-
}
179
-
180
func (mw Middleware) ResolveIdent() middlewareFunc {
181
excluded := []string{"favicon.ico"}
182
···
187
next.ServeHTTP(w, req)
188
return
189
}
190
191
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
192
if err != nil {
···
13
"github.com/bluesky-social/indigo/atproto/identity"
14
"github.com/go-chi/chi/v5"
15
"tangled.sh/tangled.sh/core/appview/db"
16
"tangled.sh/tangled.sh/core/appview/oauth"
17
"tangled.sh/tangled.sh/core/appview/pages"
18
"tangled.sh/tangled.sh/core/appview/pagination"
19
"tangled.sh/tangled.sh/core/appview/reporesolver"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
"tangled.sh/tangled.sh/core/rbac"
22
)
23
···
167
}
168
}
169
170
func (mw Middleware) ResolveIdent() middlewareFunc {
171
excluded := []string{"favicon.ico"}
172
···
177
next.ServeHTTP(w, req)
178
return
179
}
180
+
181
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
182
183
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
184
if err != nil {
+68
appview/notify/merged_notifier.go
+68
appview/notify/merged_notifier.go
···
···
1
+
package notify
2
+
3
+
import (
4
+
"context"
5
+
6
+
"tangled.sh/tangled.sh/core/appview/db"
7
+
)
8
+
9
+
type mergedNotifier struct {
10
+
notifiers []Notifier
11
+
}
12
+
13
+
func NewMergedNotifier(notifiers ...Notifier) Notifier {
14
+
return &mergedNotifier{notifiers}
15
+
}
16
+
17
+
var _ Notifier = &mergedNotifier{}
18
+
19
+
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *db.Repo) {
20
+
for _, notifier := range m.notifiers {
21
+
notifier.NewRepo(ctx, repo)
22
+
}
23
+
}
24
+
25
+
func (m *mergedNotifier) NewStar(ctx context.Context, star *db.Star) {
26
+
for _, notifier := range m.notifiers {
27
+
notifier.NewStar(ctx, star)
28
+
}
29
+
}
30
+
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *db.Star) {
31
+
for _, notifier := range m.notifiers {
32
+
notifier.DeleteStar(ctx, star)
33
+
}
34
+
}
35
+
36
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
37
+
for _, notifier := range m.notifiers {
38
+
notifier.NewIssue(ctx, issue)
39
+
}
40
+
}
41
+
42
+
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) {
43
+
for _, notifier := range m.notifiers {
44
+
notifier.NewFollow(ctx, follow)
45
+
}
46
+
}
47
+
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {
48
+
for _, notifier := range m.notifiers {
49
+
notifier.DeleteFollow(ctx, follow)
50
+
}
51
+
}
52
+
53
+
func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) {
54
+
for _, notifier := range m.notifiers {
55
+
notifier.NewPull(ctx, pull)
56
+
}
57
+
}
58
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {
59
+
for _, notifier := range m.notifiers {
60
+
notifier.NewPullComment(ctx, comment)
61
+
}
62
+
}
63
+
64
+
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {
65
+
for _, notifier := range m.notifiers {
66
+
notifier.UpdateProfile(ctx, profile)
67
+
}
68
+
}
+44
appview/notify/notifier.go
+44
appview/notify/notifier.go
···
···
1
+
package notify
2
+
3
+
import (
4
+
"context"
5
+
6
+
"tangled.sh/tangled.sh/core/appview/db"
7
+
)
8
+
9
+
type Notifier interface {
10
+
NewRepo(ctx context.Context, repo *db.Repo)
11
+
12
+
NewStar(ctx context.Context, star *db.Star)
13
+
DeleteStar(ctx context.Context, star *db.Star)
14
+
15
+
NewIssue(ctx context.Context, issue *db.Issue)
16
+
17
+
NewFollow(ctx context.Context, follow *db.Follow)
18
+
DeleteFollow(ctx context.Context, follow *db.Follow)
19
+
20
+
NewPull(ctx context.Context, pull *db.Pull)
21
+
NewPullComment(ctx context.Context, comment *db.PullComment)
22
+
23
+
UpdateProfile(ctx context.Context, profile *db.Profile)
24
+
}
25
+
26
+
// BaseNotifier is a listener that does nothing
27
+
type BaseNotifier struct{}
28
+
29
+
var _ Notifier = &BaseNotifier{}
30
+
31
+
func (m *BaseNotifier) NewRepo(ctx context.Context, repo *db.Repo) {}
32
+
33
+
func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {}
34
+
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {}
35
+
36
+
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {}
37
+
38
+
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {}
39
+
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {}
40
+
41
+
func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {}
42
+
func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {}
43
+
44
+
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
+1
-1
appview/oauth/handler/handler.go
+1
-1
appview/oauth/handler/handler.go
···
16
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
17
"tangled.sh/tangled.sh/core/appview/config"
18
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/idresolver"
20
"tangled.sh/tangled.sh/core/appview/middleware"
21
"tangled.sh/tangled.sh/core/appview/oauth"
22
"tangled.sh/tangled.sh/core/appview/oauth/client"
23
"tangled.sh/tangled.sh/core/appview/pages"
24
"tangled.sh/tangled.sh/core/knotclient"
25
"tangled.sh/tangled.sh/core/rbac"
26
)
···
16
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
17
"tangled.sh/tangled.sh/core/appview/config"
18
"tangled.sh/tangled.sh/core/appview/db"
19
"tangled.sh/tangled.sh/core/appview/middleware"
20
"tangled.sh/tangled.sh/core/appview/oauth"
21
"tangled.sh/tangled.sh/core/appview/oauth/client"
22
"tangled.sh/tangled.sh/core/appview/pages"
23
+
"tangled.sh/tangled.sh/core/idresolver"
24
"tangled.sh/tangled.sh/core/knotclient"
25
"tangled.sh/tangled.sh/core/rbac"
26
)
+73
appview/oauth/oauth.go
+73
appview/oauth/oauth.go
···
7
"net/url"
8
"time"
9
10
+
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
11
"github.com/gorilla/sessions"
12
oauth "tangled.sh/icyphox.sh/atproto-oauth"
13
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
···
205
})
206
207
return xrpcClient, nil
208
+
}
209
+
210
+
// use this to create a client to communicate with knots or spindles
211
+
//
212
+
// this is a higher level abstraction on ServerGetServiceAuth
213
+
type ServiceClientOpts struct {
214
+
service string
215
+
exp int64
216
+
lxm string
217
+
dev bool
218
+
}
219
+
220
+
type ServiceClientOpt func(*ServiceClientOpts)
221
+
222
+
func WithService(service string) ServiceClientOpt {
223
+
return func(s *ServiceClientOpts) {
224
+
s.service = service
225
+
}
226
+
}
227
+
func WithExp(exp int64) ServiceClientOpt {
228
+
return func(s *ServiceClientOpts) {
229
+
s.exp = exp
230
+
}
231
+
}
232
+
233
+
func WithLxm(lxm string) ServiceClientOpt {
234
+
return func(s *ServiceClientOpts) {
235
+
s.lxm = lxm
236
+
}
237
+
}
238
+
239
+
func WithDev(dev bool) ServiceClientOpt {
240
+
return func(s *ServiceClientOpts) {
241
+
s.dev = dev
242
+
}
243
+
}
244
+
245
+
func (s *ServiceClientOpts) Audience() string {
246
+
return fmt.Sprintf("did:web:%s", s.service)
247
+
}
248
+
249
+
func (s *ServiceClientOpts) Host() string {
250
+
scheme := "https://"
251
+
if s.dev {
252
+
scheme = "http://"
253
+
}
254
+
255
+
return scheme + s.service
256
+
}
257
+
258
+
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) {
259
+
opts := ServiceClientOpts{}
260
+
for _, o := range os {
261
+
o(&opts)
262
+
}
263
+
264
+
authorizedClient, err := o.AuthorizedClient(r)
265
+
if err != nil {
266
+
return nil, err
267
+
}
268
+
269
+
resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm)
270
+
if err != nil {
271
+
return nil, err
272
+
}
273
+
274
+
return &indigo_xrpc.Client{
275
+
Auth: &indigo_xrpc.AuthInfo{
276
+
AccessJwt: resp.Token,
277
+
},
278
+
Host: opts.Host(),
279
+
}, nil
280
}
281
282
type ClientMetadata struct {
+1
-1
appview/pages/funcmap.go
+1
-1
appview/pages/funcmap.go
+2
-2
appview/pages/markup/camo.go
+2
-2
appview/pages/markup/camo.go
···
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))
···
24
}
25
26
if rctx.CamoUrl != "" && rctx.CamoSecret != "" {
27
-
return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst)
28
}
29
30
return dst
···
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))
···
24
}
25
26
if rctx.CamoUrl != "" && rctx.CamoSecret != "" {
27
+
return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst)
28
}
29
30
return dst
+156
-10
appview/pages/pages.go
+156
-10
appview/pages/pages.go
···
16
"strings"
17
"sync"
18
19
"tangled.sh/tangled.sh/core/appview/commitverify"
20
"tangled.sh/tangled.sh/core/appview/config"
21
"tangled.sh/tangled.sh/core/appview/db"
···
30
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
31
"github.com/alecthomas/chroma/v2/lexers"
32
"github.com/alecthomas/chroma/v2/styles"
33
"github.com/bluesky-social/indigo/atproto/syntax"
34
"github.com/go-git/go-git/v5/plumbing"
35
"github.com/go-git/go-git/v5/plumbing/object"
36
-
"github.com/microcosm-cc/bluemonday"
37
)
38
39
//go:embed templates/* static
···
262
return p.executePlain("user/login", w, params)
263
}
264
265
type TimelineParams struct {
266
LoggedInUser *oauth.User
267
Timeline []db.TimelineEvent
···
391
UserDid string
392
UserHandle string
393
FollowStatus db.FollowStatus
394
-
AvatarUri string
395
Followers int
396
Following int
397
···
448
return p.executePlain("user/fragments/editPins", w, params)
449
}
450
451
-
type RepoActionsFragmentParams struct {
452
IsStarred bool
453
RepoAt syntax.ATURI
454
Stats db.RepoStats
455
}
456
457
-
func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error {
458
-
return p.executePlain("repo/fragments/repoActions", w, params)
459
}
460
461
type RepoDescriptionParams struct {
···
502
ext := filepath.Ext(params.ReadmeFileName)
503
switch ext {
504
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
505
htmlString = p.rctx.RenderMarkdown(params.Readme)
506
params.Raw = false
507
-
params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString))
508
default:
509
-
htmlString = string(params.Readme)
510
params.Raw = true
511
-
params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
512
}
513
}
514
···
555
RepoInfo repoinfo.RepoInfo
556
Active string
557
BreadCrumbs [][]string
558
-
BaseTreeLink string
559
-
BaseBlobLink string
560
types.RepoTreeResponse
561
}
562
···
626
LoggedInUser *oauth.User
627
RepoInfo repoinfo.RepoInfo
628
Active string
629
BreadCrumbs [][]string
630
ShowRendered bool
631
RenderToggle bool
···
693
Branches []types.Branch
694
Spindles []string
695
CurrentSpindle string
696
// TODO: use repoinfo.roles
697
IsCollaboratorInviteAllowed bool
698
}
···
702
return p.executeRepo("repo/settings", w, params)
703
}
704
705
type RepoIssuesParams struct {
706
LoggedInUser *oauth.User
707
RepoInfo repoinfo.RepoInfo
···
813
DidHandleMap map[string]string
814
FilteringBy db.PullState
815
Stacks map[string]db.Stack
816
}
817
818
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1069
func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
1070
params.Active = "pipelines"
1071
return p.executeRepo("repo/pipelines/workflow", w, params)
1072
}
1073
1074
func (p *Pages) Static() http.Handler {
···
16
"strings"
17
"sync"
18
19
+
"tangled.sh/tangled.sh/core/api/tangled"
20
"tangled.sh/tangled.sh/core/appview/commitverify"
21
"tangled.sh/tangled.sh/core/appview/config"
22
"tangled.sh/tangled.sh/core/appview/db"
···
31
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
32
"github.com/alecthomas/chroma/v2/lexers"
33
"github.com/alecthomas/chroma/v2/styles"
34
+
"github.com/bluesky-social/indigo/atproto/identity"
35
"github.com/bluesky-social/indigo/atproto/syntax"
36
"github.com/go-git/go-git/v5/plumbing"
37
"github.com/go-git/go-git/v5/plumbing/object"
38
)
39
40
//go:embed templates/* static
···
263
return p.executePlain("user/login", w, params)
264
}
265
266
+
func (p *Pages) Signup(w io.Writer) error {
267
+
return p.executePlain("user/signup", w, nil)
268
+
}
269
+
270
+
func (p *Pages) CompleteSignup(w io.Writer) error {
271
+
return p.executePlain("user/completeSignup", w, nil)
272
+
}
273
+
274
+
type TermsOfServiceParams struct {
275
+
LoggedInUser *oauth.User
276
+
}
277
+
278
+
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
279
+
return p.execute("legal/terms", w, params)
280
+
}
281
+
282
+
type PrivacyPolicyParams struct {
283
+
LoggedInUser *oauth.User
284
+
}
285
+
286
+
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
287
+
return p.execute("legal/privacy", w, params)
288
+
}
289
+
290
type TimelineParams struct {
291
LoggedInUser *oauth.User
292
Timeline []db.TimelineEvent
···
416
UserDid string
417
UserHandle string
418
FollowStatus db.FollowStatus
419
Followers int
420
Following int
421
···
472
return p.executePlain("user/fragments/editPins", w, params)
473
}
474
475
+
type RepoStarFragmentParams struct {
476
IsStarred bool
477
RepoAt syntax.ATURI
478
Stats db.RepoStats
479
}
480
481
+
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
482
+
return p.executePlain("repo/fragments/repoStar", w, params)
483
}
484
485
type RepoDescriptionParams struct {
···
526
ext := filepath.Ext(params.ReadmeFileName)
527
switch ext {
528
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
529
+
htmlString = p.rctx.Sanitize(htmlString)
530
htmlString = p.rctx.RenderMarkdown(params.Readme)
531
params.Raw = false
532
+
params.HTMLReadme = template.HTML(htmlString)
533
default:
534
params.Raw = true
535
}
536
}
537
···
578
RepoInfo repoinfo.RepoInfo
579
Active string
580
BreadCrumbs [][]string
581
+
TreePath string
582
types.RepoTreeResponse
583
}
584
···
648
LoggedInUser *oauth.User
649
RepoInfo repoinfo.RepoInfo
650
Active string
651
+
Unsupported bool
652
+
IsImage bool
653
+
IsVideo bool
654
+
ContentSrc string
655
BreadCrumbs [][]string
656
ShowRendered bool
657
RenderToggle bool
···
719
Branches []types.Branch
720
Spindles []string
721
CurrentSpindle string
722
+
Secrets []*tangled.RepoListSecrets_Secret
723
+
724
// TODO: use repoinfo.roles
725
IsCollaboratorInviteAllowed bool
726
}
···
730
return p.executeRepo("repo/settings", w, params)
731
}
732
733
+
type RepoGeneralSettingsParams struct {
734
+
LoggedInUser *oauth.User
735
+
RepoInfo repoinfo.RepoInfo
736
+
Active string
737
+
Tabs []map[string]any
738
+
Tab string
739
+
Branches []types.Branch
740
+
}
741
+
742
+
func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
743
+
params.Active = "settings"
744
+
return p.executeRepo("repo/settings/general", w, params)
745
+
}
746
+
747
+
type RepoAccessSettingsParams struct {
748
+
LoggedInUser *oauth.User
749
+
RepoInfo repoinfo.RepoInfo
750
+
Active string
751
+
Tabs []map[string]any
752
+
Tab string
753
+
Collaborators []Collaborator
754
+
}
755
+
756
+
func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error {
757
+
params.Active = "settings"
758
+
return p.executeRepo("repo/settings/access", w, params)
759
+
}
760
+
761
+
type RepoPipelineSettingsParams struct {
762
+
LoggedInUser *oauth.User
763
+
RepoInfo repoinfo.RepoInfo
764
+
Active string
765
+
Tabs []map[string]any
766
+
Tab string
767
+
Spindles []string
768
+
CurrentSpindle string
769
+
Secrets []map[string]any
770
+
}
771
+
772
+
func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error {
773
+
params.Active = "settings"
774
+
return p.executeRepo("repo/settings/pipelines", w, params)
775
+
}
776
+
777
type RepoIssuesParams struct {
778
LoggedInUser *oauth.User
779
RepoInfo repoinfo.RepoInfo
···
885
DidHandleMap map[string]string
886
FilteringBy db.PullState
887
Stacks map[string]db.Stack
888
+
Pipelines map[string]db.Pipeline
889
}
890
891
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1142
func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
1143
params.Active = "pipelines"
1144
return p.executeRepo("repo/pipelines/workflow", w, params)
1145
+
}
1146
+
1147
+
type PutStringParams struct {
1148
+
LoggedInUser *oauth.User
1149
+
Action string
1150
+
1151
+
// this is supplied in the case of editing an existing string
1152
+
String db.String
1153
+
}
1154
+
1155
+
func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
1156
+
return p.execute("strings/put", w, params)
1157
+
}
1158
+
1159
+
type StringsDashboardParams struct {
1160
+
LoggedInUser *oauth.User
1161
+
Card ProfileCard
1162
+
Strings []db.String
1163
+
}
1164
+
1165
+
func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
1166
+
return p.execute("strings/dashboard", w, params)
1167
+
}
1168
+
1169
+
type SingleStringParams struct {
1170
+
LoggedInUser *oauth.User
1171
+
ShowRendered bool
1172
+
RenderToggle bool
1173
+
RenderedContents template.HTML
1174
+
String db.String
1175
+
Stats db.StringStats
1176
+
Owner identity.Identity
1177
+
}
1178
+
1179
+
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1180
+
var style *chroma.Style = styles.Get("catpuccin-latte")
1181
+
1182
+
if params.ShowRendered {
1183
+
switch markup.GetFormat(params.String.Filename) {
1184
+
case markup.FormatMarkdown:
1185
+
p.rctx.RendererType = markup.RendererTypeDefault
1186
+
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
1187
+
params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString))
1188
+
}
1189
+
}
1190
+
1191
+
c := params.String.Contents
1192
+
formatter := chromahtml.New(
1193
+
chromahtml.InlineCode(false),
1194
+
chromahtml.WithLineNumbers(true),
1195
+
chromahtml.WithLinkableLineNumbers(true, "L"),
1196
+
chromahtml.Standalone(false),
1197
+
chromahtml.WithClasses(true),
1198
+
)
1199
+
1200
+
lexer := lexers.Get(filepath.Base(params.String.Filename))
1201
+
if lexer == nil {
1202
+
lexer = lexers.Fallback
1203
+
}
1204
+
1205
+
iterator, err := lexer.Tokenise(nil, c)
1206
+
if err != nil {
1207
+
return fmt.Errorf("chroma tokenize: %w", err)
1208
+
}
1209
+
1210
+
var code bytes.Buffer
1211
+
err = formatter.Format(&code, style, iterator)
1212
+
if err != nil {
1213
+
return fmt.Errorf("chroma format: %w", err)
1214
+
}
1215
+
1216
+
params.String.Contents = code.String()
1217
+
return p.execute("strings/string", w, params)
1218
}
1219
1220
func (p *Pages) Static() http.Handler {
+1
-1
appview/pages/templates/knots/fragments/addMemberModal.html
+1
-1
appview/pages/templates/knots/fragments/addMemberModal.html
···
13
<div
14
id="add-member-{{ .Id }}"
15
popover
16
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
17
{{ block "addKnotMemberPopover" . }} {{ end }}
18
</div>
19
{{ end }}
+19
-30
appview/pages/templates/layouts/base.html
+19
-30
appview/pages/templates/layouts/base.html
···
14
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
15
{{ block "extrameta" . }}{{ end }}
16
</head>
17
-
<body class="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
18
-
<div class="px-1" style="z-index: 20">
19
-
{{ block "topbarLayout" . }}
20
-
<div class="grid grid-cols-1 md:grid-cols-12">
21
-
<header class="col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;">
22
-
{{ template "layouts/topbar" . }}
23
-
</header>
24
-
</div>
25
-
{{ end }}
26
-
</div>
27
28
-
<div class="px-1 flex flex-col min-h-screen gap-4">
29
-
{{ block "contentLayout" . }}
30
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
31
<div class="col-span-1 md:col-span-2">
32
{{ block "contentLeft" . }} {{ end }}
33
</div>
···
37
<div class="col-span-1 md:col-span-2">
38
{{ block "contentRight" . }} {{ end }}
39
</div>
40
-
</div>
41
-
{{ end }}
42
-
43
-
{{ block "contentAfterLayout" . }}
44
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
45
<div class="col-span-1 md:col-span-2">
46
{{ block "contentAfterLeft" . }} {{ end }}
47
</div>
···
51
<div class="col-span-1 md:col-span-2">
52
{{ block "contentAfterRight" . }} {{ end }}
53
</div>
54
-
</div>
55
-
{{ end }}
56
-
</div>
57
-
58
-
<div class="px-1 mt-16">
59
-
{{ block "footerLayout" . }}
60
-
<div class="grid grid-cols-1 md:grid-cols-12">
61
-
<footer class="col-span-1 md:col-start-3 md:col-span-8">
62
-
{{ template "layouts/footer" . }}
63
-
</footer>
64
</div>
65
-
{{ end }}
66
-
</div>
67
68
</body>
69
</html>
70
{{ end }}
···
14
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
15
{{ block "extrameta" . }}{{ end }}
16
</head>
17
+
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
18
+
{{ block "topbarLayout" . }}
19
+
<header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;">
20
+
{{ template "layouts/topbar" . }}
21
+
</header>
22
+
{{ end }}
23
24
+
{{ block "mainLayout" . }}
25
+
<div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4">
26
+
{{ block "contentLayout" . }}
27
<div class="col-span-1 md:col-span-2">
28
{{ block "contentLeft" . }} {{ end }}
29
</div>
···
33
<div class="col-span-1 md:col-span-2">
34
{{ block "contentRight" . }} {{ end }}
35
</div>
36
+
{{ end }}
37
+
38
+
{{ block "contentAfterLayout" . }}
39
<div class="col-span-1 md:col-span-2">
40
{{ block "contentAfterLeft" . }} {{ end }}
41
</div>
···
45
<div class="col-span-1 md:col-span-2">
46
{{ block "contentAfterRight" . }} {{ end }}
47
</div>
48
+
{{ end }}
49
</div>
50
+
{{ end }}
51
52
+
{{ block "footerLayout" . }}
53
+
<footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12">
54
+
{{ template "layouts/footer" . }}
55
+
</footer>
56
+
{{ end }}
57
</body>
58
</html>
59
{{ end }}
+23
-1
appview/pages/templates/layouts/repobase.html
+23
-1
appview/pages/templates/layouts/repobase.html
···
19
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
20
</div>
21
22
+
<div class="flex items-center gap-2 z-auto">
23
+
{{ template "repo/fragments/repoStar" .RepoInfo }}
24
+
{{ if .RepoInfo.DisableFork }}
25
+
<button
26
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
27
+
disabled
28
+
title="Empty repositories cannot be forked"
29
+
>
30
+
{{ i "git-fork" "w-4 h-4" }}
31
+
fork
32
+
</button>
33
+
{{ else }}
34
+
<a
35
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
36
+
hx-boost="true"
37
+
href="/{{ .RepoInfo.FullName }}/fork"
38
+
>
39
+
{{ i "git-fork" "w-4 h-4" }}
40
+
fork
41
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
42
+
</a>
43
+
{{ end }}
44
+
</div>
45
</div>
46
{{ template "repo/fragments/repoDescription" . }}
47
</section>
+27
-17
appview/pages/templates/layouts/topbar.html
+27
-17
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 dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
<div class="flex justify-between p-0 items-center">
4
<div id="left-items">
5
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
6
tangled<sub>alpha</sub>
7
</a>
8
</div>
9
-
<div class="hidden md:flex gap-4 items-center">
10
-
<a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center">
11
-
{{ i "message-circle" "size-4" }} discord
12
-
</a>
13
14
-
<a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center">
15
-
{{ i "hash" "size-4" }} irc
16
-
</a>
17
-
18
-
<a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center">
19
-
{{ i "code" "size-4" }} source
20
-
</a>
21
-
</div>
22
-
<div id="right-items" class="flex items-center gap-4">
23
{{ with .LoggedInUser }}
24
-
<a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white">
25
-
{{ i "plus" "w-4 h-4" }}
26
-
</a>
27
{{ block "dropDown" . }} {{ end }}
28
{{ else }}
29
<a href="/login">login</a>
30
{{ end }}
31
</div>
32
</div>
33
</nav>
34
{{ end }}
35
36
{{ define "dropDown" }}
37
<details class="relative inline-block text-left">
38
<summary
···
45
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"
46
>
47
<a href="/{{ $user }}">profile</a>
48
<a href="/knots">knots</a>
49
<a href="/spindles">spindles</a>
50
<a href="/settings">settings</a>
···
1
{{ define "layouts/topbar" }}
2
+
<nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
<div class="flex justify-between p-0 items-center">
4
<div id="left-items">
5
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
6
tangled<sub>alpha</sub>
7
</a>
8
</div>
9
10
+
<div id="right-items" class="flex items-center gap-2">
11
{{ with .LoggedInUser }}
12
+
{{ block "newButton" . }} {{ end }}
13
{{ block "dropDown" . }} {{ end }}
14
{{ else }}
15
<a href="/login">login</a>
16
+
<span class="text-gray-500 dark:text-gray-400">or</span>
17
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
18
+
join now {{ i "arrow-right" "size-4" }}
19
+
</a>
20
{{ end }}
21
</div>
22
</div>
23
</nav>
24
{{ end }}
25
26
+
{{ define "newButton" }}
27
+
<details class="relative inline-block text-left">
28
+
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
29
+
{{ i "plus" "w-4 h-4" }} new
30
+
</summary>
31
+
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
32
+
<a href="/repo/new" class="flex items-center gap-2">
33
+
{{ i "book-plus" "w-4 h-4" }}
34
+
new repository
35
+
</a>
36
+
<a href="/strings/new" class="flex items-center gap-2">
37
+
{{ i "line-squiggle" "w-4 h-4" }}
38
+
new string
39
+
</a>
40
+
</div>
41
+
</details>
42
+
{{ end }}
43
+
44
{{ define "dropDown" }}
45
<details class="relative inline-block text-left">
46
<summary
···
53
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"
54
>
55
<a href="/{{ $user }}">profile</a>
56
+
<a href="/{{ $user }}?tab=repos">repositories</a>
57
+
<a href="/strings/{{ $user }}">strings</a>
58
<a href="/knots">knots</a>
59
<a href="/spindles">spindles</a>
60
<a href="/settings">settings</a>
+133
appview/pages/templates/legal/privacy.html
+133
appview/pages/templates/legal/privacy.html
···
···
1
+
{{ define "title" }} privacy policy {{ end }}
2
+
{{ define "content" }}
3
+
<div class="max-w-4xl mx-auto px-4 py-8">
4
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
5
+
<div class="prose prose-gray dark:prose-invert max-w-none">
6
+
<h1>Privacy Policy</h1>
7
+
8
+
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
9
+
10
+
<p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p>
11
+
12
+
<h2>1. Information We Collect</h2>
13
+
14
+
<h3>Account Information</h3>
15
+
<p>When you create an account, we collect:</p>
16
+
<ul>
17
+
<li>Your chosen username</li>
18
+
<li>Email address</li>
19
+
<li>Profile information you choose to provide</li>
20
+
<li>Authentication data</li>
21
+
</ul>
22
+
23
+
<h3>Content and Activity</h3>
24
+
<p>We store:</p>
25
+
<ul>
26
+
<li>Code repositories and associated metadata</li>
27
+
<li>Issues, pull requests, and comments</li>
28
+
<li>Activity logs and usage patterns</li>
29
+
<li>Public keys for authentication</li>
30
+
</ul>
31
+
32
+
<h2>2. Data Location and Hosting</h2>
33
+
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6">
34
+
<h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3>
35
+
<p class="text-blue-700 dark:text-blue-300">
36
+
<strong>All Tangled service data is hosted within the European Union.</strong> Specifically:
37
+
</p>
38
+
<ul class="text-blue-700 dark:text-blue-300 mt-2">
39
+
<li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li>
40
+
<li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li>
41
+
<li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li>
42
+
</ul>
43
+
</div>
44
+
45
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6">
46
+
<h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3>
47
+
<p class="text-yellow-700 dark:text-yellow-300">
48
+
<strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure.
49
+
</p>
50
+
</div>
51
+
52
+
<h2>3. Third-Party Data Processors</h2>
53
+
<p>We only share your data with the following third-party processors:</p>
54
+
55
+
<h3>Resend (Email Services)</h3>
56
+
<ul>
57
+
<li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li>
58
+
<li><strong>Data Shared:</strong> Email address and necessary message content</li>
59
+
<li><strong>Location:</strong> EU-compliant email delivery service</li>
60
+
</ul>
61
+
62
+
<h3>Cloudflare (Image Caching)</h3>
63
+
<ul>
64
+
<li><strong>Purpose:</strong> Caching and optimizing image delivery</li>
65
+
<li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li>
66
+
<li><strong>Location:</strong> Global CDN with EU data protection compliance</li>
67
+
</ul>
68
+
69
+
<h2>4. How We Use Your Information</h2>
70
+
<p>We use your information to:</p>
71
+
<ul>
72
+
<li>Provide and maintain the Service</li>
73
+
<li>Process your transactions and requests</li>
74
+
<li>Send you technical notices and support messages</li>
75
+
<li>Improve and develop new features</li>
76
+
<li>Ensure security and prevent fraud</li>
77
+
<li>Comply with legal obligations</li>
78
+
</ul>
79
+
80
+
<h2>5. Data Sharing and Disclosure</h2>
81
+
<p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p>
82
+
<ul>
83
+
<li>With the third-party processors listed above</li>
84
+
<li>When required by law or legal process</li>
85
+
<li>To protect our rights, property, or safety, or that of our users</li>
86
+
<li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li>
87
+
</ul>
88
+
89
+
<h2>6. Data Security</h2>
90
+
<p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p>
91
+
92
+
<h2>7. Data Retention</h2>
93
+
<p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p>
94
+
95
+
<h2>8. Your Rights</h2>
96
+
<p>Under applicable data protection laws, you have the right to:</p>
97
+
<ul>
98
+
<li>Access your personal information</li>
99
+
<li>Correct inaccurate information</li>
100
+
<li>Request deletion of your information</li>
101
+
<li>Object to processing of your information</li>
102
+
<li>Data portability</li>
103
+
<li>Withdraw consent (where applicable)</li>
104
+
</ul>
105
+
106
+
<h2>9. Cookies and Tracking</h2>
107
+
<p>We use cookies and similar technologies to:</p>
108
+
<ul>
109
+
<li>Maintain your login session</li>
110
+
<li>Remember your preferences</li>
111
+
<li>Analyze usage patterns to improve the Service</li>
112
+
</ul>
113
+
<p>You can control cookie settings through your browser preferences.</p>
114
+
115
+
<h2>10. Children's Privacy</h2>
116
+
<p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p>
117
+
118
+
<h2>11. International Data Transfers</h2>
119
+
<p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p>
120
+
121
+
<h2>12. Changes to This Privacy Policy</h2>
122
+
<p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p>
123
+
124
+
<h2>13. Contact Information</h2>
125
+
<p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p>
126
+
127
+
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
128
+
<p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p>
129
+
</div>
130
+
</div>
131
+
</div>
132
+
</div>
133
+
{{ end }}
+71
appview/pages/templates/legal/terms.html
+71
appview/pages/templates/legal/terms.html
···
···
1
+
{{ define "title" }}terms of service{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="max-w-4xl mx-auto px-4 py-8">
5
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
+
<div class="prose prose-gray dark:prose-invert max-w-none">
7
+
<h1>Terms of Service</h1>
8
+
9
+
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
10
+
11
+
<p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p>
12
+
13
+
<h2>1. Acceptance of Terms</h2>
14
+
<p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p>
15
+
16
+
<h2>2. Account Registration</h2>
17
+
<p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p>
18
+
19
+
<h2>3. Account Termination</h2>
20
+
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6">
21
+
<h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3>
22
+
<p class="text-red-700 dark:text-red-300">
23
+
<strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users.
24
+
</p>
25
+
<p class="text-red-700 dark:text-red-300 mt-2">
26
+
Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion.
27
+
</p>
28
+
</div>
29
+
30
+
<h2>4. Acceptable Use</h2>
31
+
<p>You agree not to use the Service to:</p>
32
+
<ul>
33
+
<li>Violate any applicable laws or regulations</li>
34
+
<li>Infringe upon the rights of others</li>
35
+
<li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li>
36
+
<li>Engage in spam, phishing, or other deceptive practices</li>
37
+
<li>Attempt to gain unauthorized access to the Service or other users' accounts</li>
38
+
<li>Interfere with or disrupt the Service or servers connected to the Service</li>
39
+
</ul>
40
+
41
+
<h2>5. Content and Intellectual Property</h2>
42
+
<p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p>
43
+
44
+
<h2>6. Privacy</h2>
45
+
<p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p>
46
+
47
+
<h2>7. Disclaimers</h2>
48
+
<p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p>
49
+
50
+
<h2>8. Limitation of Liability</h2>
51
+
<p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p>
52
+
53
+
<h2>9. Indemnification</h2>
54
+
<p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p>
55
+
56
+
<h2>10. Governing Law</h2>
57
+
<p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p>
58
+
59
+
<h2>11. Changes to Terms</h2>
60
+
<p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p>
61
+
62
+
<h2>12. Contact Information</h2>
63
+
<p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p>
64
+
65
+
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
66
+
<p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p>
67
+
</div>
68
+
</div>
69
+
</div>
70
+
</div>
71
+
{{ end }}
+19
-6
appview/pages/templates/repo/blob.html
+19
-6
appview/pages/templates/repo/blob.html
···
5
6
{{ $title := printf "%s at %s · %s" .Path .Ref .RepoInfo.FullName }}
7
{{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
8
-
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
-
11
{{ end }}
12
13
{{ define "repoContent" }}
···
44
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
45
{{ if .RenderToggle }}
46
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
47
-
<a
48
-
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
49
hx-boost="true"
50
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
51
{{ end }}
52
</div>
53
</div>
54
</div>
55
-
{{ if .IsBinary }}
56
<p class="text-center text-gray-400 dark:text-gray-500">
57
-
This is a binary file and will not be displayed.
58
</p>
59
{{ else }}
60
<div class="overflow-auto relative">
61
{{ if .ShowRendered }}
···
5
6
{{ $title := printf "%s at %s · %s" .Path .Ref .RepoInfo.FullName }}
7
{{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
8
+
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
+
11
{{ end }}
12
13
{{ define "repoContent" }}
···
44
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
45
{{ if .RenderToggle }}
46
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
47
+
<a
48
+
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
49
hx-boost="true"
50
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
51
{{ end }}
52
</div>
53
</div>
54
</div>
55
+
{{ if and .IsBinary .Unsupported }}
56
<p class="text-center text-gray-400 dark:text-gray-500">
57
+
Previews are not supported for this file type.
58
</p>
59
+
{{ else if .IsBinary }}
60
+
<div class="text-center">
61
+
{{ if .IsImage }}
62
+
<img src="{{ .ContentSrc }}"
63
+
alt="{{ .Path }}"
64
+
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
65
+
{{ else if .IsVideo }}
66
+
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
67
+
<source src="{{ .ContentSrc }}">
68
+
Your browser does not support the video tag.
69
+
</video>
70
+
{{ end }}
71
+
</div>
72
{{ else }}
73
<div class="overflow-auto relative">
74
{{ if .ShowRendered }}
+20
-14
appview/pages/templates/repo/commit.html
+20
-14
appview/pages/templates/repo/commit.html
···
80
{{end}}
81
82
{{ define "topbarLayout" }}
83
-
<header style="z-index: 20;">
84
{{ template "layouts/topbar" . }}
85
</header>
86
{{ end }}
87
88
-
{{ define "contentLayout" }}
89
-
{{ block "content" . }}{{ end }}
90
-
{{ end }}
91
92
-
{{ define "contentAfterLayout" }}
93
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
94
-
<div class="col-span-1 md:col-span-2">
95
-
{{ block "contentAfterLeft" . }} {{ end }}
96
</div>
97
-
<main class="col-span-1 md:col-span-10">
98
-
{{ block "contentAfter" . }}{{ end }}
99
-
</main>
100
</div>
101
{{ end }}
102
103
-
{{ define "footerLayout" }}
104
-
{{ template "layouts/footer" . }}
105
{{ end }}
106
107
{{ define "contentAfter" }}
···
112
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
113
{{ template "repo/fragments/diffOpts" .DiffOpts }}
114
</div>
115
-
<div class="sticky top-0 mt-4">
116
{{ template "repo/fragments/diffChangedFiles" .Diff }}
117
</div>
118
{{end}}
···
80
{{end}}
81
82
{{ define "topbarLayout" }}
83
+
<header class="px-1 col-span-full" style="z-index: 20;">
84
{{ template "layouts/topbar" . }}
85
</header>
86
{{ end }}
87
88
+
{{ define "mainLayout" }}
89
+
<div class="px-1 col-span-full flex flex-col gap-4">
90
+
{{ block "contentLayout" . }}
91
+
{{ block "content" . }}{{ end }}
92
+
{{ end }}
93
94
+
{{ block "contentAfterLayout" . }}
95
+
<div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4">
96
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
97
+
{{ block "contentAfterLeft" . }} {{ end }}
98
+
</div>
99
+
<main class="col-span-1 md:col-span-10">
100
+
{{ block "contentAfter" . }}{{ end }}
101
+
</main>
102
</div>
103
+
{{ end }}
104
</div>
105
{{ end }}
106
107
+
{{ define "footerLayout" }}
108
+
<footer class="px-1 col-span-full mt-12">
109
+
{{ template "layouts/footer" . }}
110
+
</footer>
111
{{ end }}
112
113
{{ define "contentAfter" }}
···
118
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
119
{{ template "repo/fragments/diffOpts" .DiffOpts }}
120
</div>
121
+
<div class="sticky top-0 flex-grow max-h-screen">
122
{{ template "repo/fragments/diffChangedFiles" .Diff }}
123
</div>
124
{{end}}
+22
-14
appview/pages/templates/repo/compare/compare.html
+22
-14
appview/pages/templates/repo/compare/compare.html
···
11
{{ end }}
12
13
{{ define "topbarLayout" }}
14
-
{{ template "layouts/topbar" . }}
15
{{ end }}
16
17
-
{{ define "contentLayout" }}
18
-
{{ block "content" . }}{{ end }}
19
-
{{ end }}
20
21
-
{{ define "contentAfterLayout" }}
22
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
23
-
<div class="col-span-1 md:col-span-2">
24
-
{{ block "contentAfterLeft" . }} {{ end }}
25
</div>
26
-
<main class="col-span-1 md:col-span-10">
27
-
{{ block "contentAfter" . }}{{ end }}
28
-
</main>
29
</div>
30
{{ end }}
31
32
-
{{ define "footerLayout" }}
33
-
{{ template "layouts/footer" . }}
34
{{ end }}
35
36
{{ define "contentAfter" }}
···
41
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
42
{{ template "repo/fragments/diffOpts" .DiffOpts }}
43
</div>
44
-
<div class="sticky top-0 mt-4">
45
{{ template "repo/fragments/diffChangedFiles" .Diff }}
46
</div>
47
{{end}}
···
11
{{ end }}
12
13
{{ define "topbarLayout" }}
14
+
<header class="px-1 col-span-full" style="z-index: 20;">
15
+
{{ template "layouts/topbar" . }}
16
+
</header>
17
{{ end }}
18
19
+
{{ define "mainLayout" }}
20
+
<div class="px-1 col-span-full flex flex-col gap-4">
21
+
{{ block "contentLayout" . }}
22
+
{{ block "content" . }}{{ end }}
23
+
{{ end }}
24
25
+
{{ block "contentAfterLayout" . }}
26
+
<div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4">
27
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
28
+
{{ block "contentAfterLeft" . }} {{ end }}
29
+
</div>
30
+
<main class="col-span-1 md:col-span-10">
31
+
{{ block "contentAfter" . }}{{ end }}
32
+
</main>
33
</div>
34
+
{{ end }}
35
</div>
36
{{ end }}
37
38
+
{{ define "footerLayout" }}
39
+
<footer class="px-1 col-span-full mt-12">
40
+
{{ template "layouts/footer" . }}
41
+
</footer>
42
{{ end }}
43
44
{{ define "contentAfter" }}
···
49
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
50
{{ template "repo/fragments/diffOpts" .DiffOpts }}
51
</div>
52
+
<div class="sticky top-0 flex-grow max-h-screen">
53
{{ template "repo/fragments/diffChangedFiles" .Diff }}
54
</div>
55
{{end}}
+15
-3
appview/pages/templates/repo/empty.html
+15
-3
appview/pages/templates/repo/empty.html
···
23
{{ end }}
24
</div>
25
</div>
26
+
{{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }}
27
+
{{ $knot := .RepoInfo.Knot }}
28
+
{{ if eq $knot "knot1.tangled.sh" }}
29
+
{{ $knot = "tangled.sh" }}
30
+
{{ end }}
31
+
<div class="w-full flex place-content-center">
32
+
<div class="py-6 w-fit flex flex-col gap-4">
33
+
<p>This is an empty repository. To get started:</p>
34
+
{{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }}
35
+
<p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p>
36
+
<p><span class="{{$bullet}}">2</span>Configure your remote to <span class="font-mono p-1 rounded bg-gray-100 dark:bg-gray-700 ">git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}<span></p>
37
+
<p><span class="{{$bullet}}">3</span>Push!</p>
38
+
</div>
39
+
</div>
40
{{ else }}
41
+
<p class="text-gray-400 dark:text-gray-500 py-6 text-center">This is an empty repository.</p>
42
{{ end }}
43
</main>
44
{{ end }}
+1
-1
appview/pages/templates/repo/fragments/diffChangedFiles.html
+1
-1
appview/pages/templates/repo/fragments/diffChangedFiles.html
···
1
{{ define "repo/fragments/diffChangedFiles" }}
2
{{ $stat := .Stat }}
3
{{ $fileTree := fileTree .ChangedFiles }}
4
-
<section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto md:min-h-screen rounded bg-white dark:bg-gray-800 drop-shadow-sm">
5
<div class="diff-stat">
6
<div class="flex gap-2 items-center">
7
<strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
···
1
{{ define "repo/fragments/diffChangedFiles" }}
2
{{ $stat := .Stat }}
3
{{ $fileTree := fileTree .ChangedFiles }}
4
+
<section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm">
5
<div class="diff-stat">
6
<div class="flex gap-2 items-center">
7
<strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
+1
-1
appview/pages/templates/repo/fragments/interdiffFiles.html
+1
-1
appview/pages/templates/repo/fragments/interdiffFiles.html
···
1
{{ define "repo/fragments/interdiffFiles" }}
2
{{ $fileTree := fileTree .AffectedFiles }}
3
-
<section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm md:min-h-screen text-sm">
4
<div class="diff-stat">
5
<div class="flex gap-2 items-center">
6
<strong class="text-sm uppercase dark:text-gray-200">files</strong>
···
1
{{ define "repo/fragments/interdiffFiles" }}
2
{{ $fileTree := fileTree .AffectedFiles }}
3
+
<section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm">
4
<div class="diff-stat">
5
<div class="flex gap-2 items-center">
6
<strong class="text-sm uppercase dark:text-gray-200">files</strong>
-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 }}
···
+26
appview/pages/templates/repo/fragments/repoStar.html
+26
appview/pages/templates/repo/fragments/repoStar.html
···
···
1
+
{{ define "repo/fragments/repoStar" }}
2
+
<button
3
+
id="starBtn"
4
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
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="this"
13
+
hx-swap="outerHTML"
14
+
hx-disabled-elt="#starBtn"
15
+
>
16
+
{{ if .IsStarred }}
17
+
{{ i "star" "w-4 h-4 fill-current" }}
18
+
{{ else }}
19
+
{{ i "star" "w-4 h-4" }}
20
+
{{ end }}
21
+
<span class="text-sm">
22
+
{{ .Stats.StarCount }}
23
+
</span>
24
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
25
+
</button>
26
+
{{ end }}
+38
-70
appview/pages/templates/repo/index.html
+38
-70
appview/pages/templates/repo/index.html
···
127
{{ end }}
128
129
{{ define "fileTree" }}
130
-
<div
131
-
id="file-tree"
132
-
class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700"
133
-
>
134
-
{{ $containerstyle := "py-1" }}
135
-
{{ $linkstyle := "no-underline hover:underline dark:text-white" }}
136
137
-
{{ range .Files }}
138
-
{{ if not .IsFile }}
139
-
<div class="{{ $containerstyle }}">
140
-
<div class="flex justify-between items-center">
141
-
<a
142
-
href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}"
143
-
class="{{ $linkstyle }}"
144
-
>
145
-
<div class="flex items-center gap-2">
146
-
{{ i "folder" "size-4 fill-current" }}
147
-
{{ .Name }}
148
-
</div>
149
-
</a>
150
-
151
-
{{ if .LastCommit }}
152
-
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span>
153
-
{{ end }}
154
-
</div>
155
-
</div>
156
-
{{ end }}
157
-
{{ end }}
158
159
-
{{ range .Files }}
160
-
{{ if .IsFile }}
161
-
<div class="{{ $containerstyle }}">
162
-
<div class="flex justify-between items-center">
163
-
<a
164
-
href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}"
165
-
class="{{ $linkstyle }}"
166
-
>
167
-
<div class="flex items-center gap-2">
168
-
{{ i "file" "size-4" }}{{ .Name }}
169
-
</div>
170
-
</a>
171
172
-
{{ if .LastCommit }}
173
-
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span>
174
-
{{ end }}
175
-
</div>
176
-
</div>
177
-
{{ end }}
178
-
{{ end }}
179
-
</div>
180
{{ end }}
181
182
{{ define "rightInfo" }}
···
190
{{ define "commitLog" }}
191
<div id="commit-log" class="md:col-span-1 px-2 pb-4">
192
<div class="flex justify-between items-center">
193
-
<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">
194
-
<div class="flex gap-2 items-center font-bold">
195
-
{{ i "logs" "w-4 h-4" }} commits
196
-
</div>
197
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
198
-
view {{ .TotalCommits }} commits {{ i "chevron-right" "w-4 h-4" }}
199
-
</span>
200
</a>
201
</div>
202
<div class="flex flex-col gap-6">
···
298
{{ define "branchList" }}
299
{{ if gt (len .BranchesTrunc) 0 }}
300
<div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
301
-
<a href="/{{ .RepoInfo.FullName }}/branches" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
302
-
<div class="flex gap-2 items-center font-bold">
303
-
{{ i "git-branch" "w-4 h-4" }} branches
304
-
</div>
305
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
306
-
view {{ len .Branches }} branches {{ i "chevron-right" "w-4 h-4" }}
307
-
</span>
308
</a>
309
<div class="flex flex-col gap-1">
310
{{ range .BranchesTrunc }}
···
341
{{ if gt (len .TagsTrunc) 0 }}
342
<div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
343
<div class="flex justify-between items-center">
344
-
<a href="/{{ .RepoInfo.FullName }}/tags" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
345
-
<div class="flex gap-2 items-center font-bold">
346
-
{{ i "tags" "w-4 h-4" }} tags
347
-
</div>
348
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
349
-
view {{ len .Tags }} tags {{ i "chevron-right" "w-4 h-4" }}
350
-
</span>
351
</a>
352
</div>
353
<div class="flex flex-col gap-1">
···
378
{{ end }}
379
380
{{ define "repoAfter" }}
381
-
{{- if .HTMLReadme -}}
382
<section
383
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 }}
384
prose dark:prose-invert dark:[&_pre]:bg-gray-900
···
387
{{ end }}"
388
>
389
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
390
-
{{- .HTMLReadme -}}
391
</pre>
392
{{- else -}}
393
{{ .HTMLReadme }}
···
127
{{ end }}
128
129
{{ define "fileTree" }}
130
+
<div id="file-tree" class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700" >
131
+
{{ $linkstyle := "no-underline hover:underline dark:text-white" }}
132
133
+
{{ range .Files }}
134
+
<div class="grid grid-cols-2 gap-4 items-center py-1">
135
+
<div class="col-span-1">
136
+
{{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }}
137
+
{{ $icon := "folder" }}
138
+
{{ $iconStyle := "size-4 fill-current" }}
139
140
+
{{ if .IsFile }}
141
+
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
142
+
{{ $icon = "file" }}
143
+
{{ $iconStyle = "size-4" }}
144
+
{{ end }}
145
+
<a href="{{ $link }}" class="{{ $linkstyle }}">
146
+
<div class="flex items-center gap-2">
147
+
{{ i $icon $iconStyle }}{{ .Name }}
148
+
</div>
149
+
</a>
150
+
</div>
151
152
+
<div class="text-xs col-span-1 text-right">
153
+
{{ with .LastCommit }}
154
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a>
155
+
{{ end }}
156
+
</div>
157
+
</div>
158
+
{{ end }}
159
+
</div>
160
{{ end }}
161
162
{{ define "rightInfo" }}
···
170
{{ define "commitLog" }}
171
<div id="commit-log" class="md:col-span-1 px-2 pb-4">
172
<div class="flex justify-between items-center">
173
+
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
174
+
{{ i "logs" "w-4 h-4" }} commits
175
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span>
176
</a>
177
</div>
178
<div class="flex flex-col gap-6">
···
274
{{ define "branchList" }}
275
{{ if gt (len .BranchesTrunc) 0 }}
276
<div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
277
+
<a href="/{{ .RepoInfo.FullName }}/branches" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
278
+
{{ i "git-branch" "w-4 h-4" }} branches
279
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Branches }}</span>
280
</a>
281
<div class="flex flex-col gap-1">
282
{{ range .BranchesTrunc }}
···
313
{{ if gt (len .TagsTrunc) 0 }}
314
<div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
315
<div class="flex justify-between items-center">
316
+
<a href="/{{ .RepoInfo.FullName }}/tags" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
317
+
{{ i "tags" "w-4 h-4" }} tags
318
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Tags }}</span>
319
</a>
320
</div>
321
<div class="flex flex-col gap-1">
···
346
{{ end }}
347
348
{{ define "repoAfter" }}
349
+
{{- if or .HTMLReadme .Readme -}}
350
<section
351
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 }}
352
prose dark:prose-invert dark:[&_pre]:bg-gray-900
···
355
{{ end }}"
356
>
357
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
358
+
{{- .Readme -}}
359
</pre>
360
{{- else -}}
361
{{ .HTMLReadme }}
+2
-4
appview/pages/templates/repo/issues/fragments/editIssueComment.html
+2
-4
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
···
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
{{ template "repo/fragments/time" .Created }}
23
</a>
···
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 dark:text-gray-400 text-sm flex-wrap">
5
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
6
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
7
···
9
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
10
{{ if $isIssueAuthor }}
11
<span class="before:content-['ยท']"></span>
12
author
13
{{ end }}
14
15
<span class="before:content-['ยท']"></span>
16
<a
17
href="#{{ .CommentId }}"
18
+
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
19
id="{{ .CommentId }}">
20
{{ template "repo/fragments/time" .Created }}
21
</a>
+7
-8
appview/pages/templates/repo/issues/fragments/issueComment.html
+7
-8
appview/pages/templates/repo/issues/fragments/issueComment.html
···
5
{{ $owner := index $.DidHandleMap .OwnerDid }}
6
{{ template "user/fragments/picHandleLink" $owner }}
7
8
<span class="before:content-['ยท']"></span>
9
<a
10
href="#{{ .CommentId }}"
···
18
{{ template "repo/fragments/time" .Created }}
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) }}
···
5
{{ $owner := index $.DidHandleMap .OwnerDid }}
6
{{ template "user/fragments/picHandleLink" $owner }}
7
8
+
<!-- show user "hats" -->
9
+
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
10
+
{{ if $isIssueAuthor }}
11
+
<span class="before:content-['ยท']"></span>
12
+
author
13
+
{{ end }}
14
+
15
<span class="before:content-['ยท']"></span>
16
<a
17
href="#{{ .CommentId }}"
···
25
{{ template "repo/fragments/time" .Created }}
26
{{ end }}
27
</a>
28
29
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
30
{{ if and $isCommentOwner (not .Deleted) }}
+72
-75
appview/pages/templates/repo/log.html
+72
-75
appview/pages/templates/repo/log.html
···
14
</h2>
15
16
<!-- desktop view (hidden on small screens) -->
17
-
<table class="w-full border-collapse hidden md:table">
18
-
<thead>
19
-
<tr>
20
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Author</th>
21
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Commit</th>
22
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Message</th>
23
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold"></th>
24
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Date</th>
25
-
</tr>
26
-
</thead>
27
-
<tbody>
28
-
{{ range $index, $commit := .Commits }}
29
-
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
30
-
<tr class="{{ if ne $index (sub (len $.Commits) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}">
31
-
<td class=" py-3 align-top">
32
-
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
33
-
{{ if $didOrHandle }}
34
-
{{ template "user/fragments/picHandleLink" $didOrHandle }}
35
-
{{ else }}
36
-
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
37
-
{{ end }}
38
-
</td>
39
-
<td class="py-3 align-top font-mono flex items-center">
40
-
{{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }}
41
-
{{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }}
42
-
{{ if $verified }}
43
-
{{ $hashStyle = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 rounded" }}
44
-
{{ end }}
45
-
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2">
46
-
{{ slice $commit.Hash.String 0 8 }}
47
-
{{ if $verified }}
48
-
{{ i "shield-check" "w-4 h-4" }}
49
-
{{ end }}
50
-
</a>
51
-
<div class="{{ if not $verified }} ml-6 {{ end }}inline-flex">
52
-
<button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
53
-
title="Copy SHA"
54
-
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)">
55
-
{{ i "copy" "w-4 h-4" }}
56
-
</button>
57
-
<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">
58
-
{{ i "folder-code" "w-4 h-4" }}
59
-
</a>
60
-
</div>
61
62
-
</td>
63
-
<td class=" py-3 align-top">
64
-
<div class="flex items-center justify-start gap-2">
65
-
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a>
66
-
{{ if gt (len $messageParts) 1 }}
67
-
<button class="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>
68
-
{{ end }}
69
70
-
{{ if index $.TagMap $commit.Hash.String }}
71
-
{{ range $tag := index $.TagMap $commit.Hash.String }}
72
-
<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">
73
-
{{ $tag }}
74
-
</span>
75
-
{{ end }}
76
-
{{ end }}
77
-
</div>
78
79
-
{{ if gt (len $messageParts) 1 }}
80
-
<p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p>
81
-
{{ end }}
82
-
</td>
83
-
<td class="py-3 align-top">
84
-
<!-- ci status -->
85
-
{{ $pipeline := index $.Pipelines .Hash.String }}
86
-
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
87
-
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
88
-
{{ end }}
89
-
</td>
90
-
<td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $commit.Committer.When }}</td>
91
-
</tr>
92
-
{{ end }}
93
-
</tbody>
94
-
</table>
95
96
<!-- mobile view (visible only on small screens) -->
97
<div class="md:hidden">
···
14
</h2>
15
16
<!-- desktop view (hidden on small screens) -->
17
+
<div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700">
18
+
{{ $grid := "grid grid-cols-14 gap-4" }}
19
+
<div class="{{ $grid }}">
20
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Author</div>
21
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div>
22
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div>
23
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div>
24
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div>
25
+
</div>
26
+
{{ range $index, $commit := .Commits }}
27
+
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
28
+
<div class="{{ $grid }} py-3">
29
+
<div class="align-top truncate col-span-2">
30
+
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
31
+
{{ if $didOrHandle }}
32
+
{{ template "user/fragments/picHandleLink" $didOrHandle }}
33
+
{{ else }}
34
+
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
35
+
{{ end }}
36
+
</div>
37
+
<div class="align-top font-mono flex items-start col-span-3">
38
+
{{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }}
39
+
{{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }}
40
+
{{ if $verified }}
41
+
{{ $hashStyle = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 rounded" }}
42
+
{{ end }}
43
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2">
44
+
{{ slice $commit.Hash.String 0 8 }}
45
+
{{ if $verified }}
46
+
{{ i "shield-check" "w-4 h-4" }}
47
+
{{ end }}
48
+
</a>
49
+
<div class="{{ if not $verified }} ml-6 {{ end }}inline-flex">
50
+
<button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
51
+
title="Copy SHA"
52
+
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)">
53
+
{{ i "copy" "w-4 h-4" }}
54
+
</button>
55
+
<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">
56
+
{{ i "folder-code" "w-4 h-4" }}
57
+
</a>
58
+
</div>
59
60
+
</div>
61
+
<div class="align-top col-span-6">
62
+
<div>
63
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a>
64
+
{{ if gt (len $messageParts) 1 }}
65
+
<button class="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>
66
+
{{ end }}
67
68
+
{{ if index $.TagMap $commit.Hash.String }}
69
+
{{ range $tag := index $.TagMap $commit.Hash.String }}
70
+
<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">
71
+
{{ $tag }}
72
+
</span>
73
+
{{ end }}
74
+
{{ end }}
75
+
</div>
76
77
+
{{ if gt (len $messageParts) 1 }}
78
+
<p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p>
79
+
{{ end }}
80
+
</div>
81
+
<div class="align-top col-span-1">
82
+
<!-- ci status -->
83
+
{{ $pipeline := index $.Pipelines .Hash.String }}
84
+
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
85
+
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
86
+
{{ end }}
87
+
</div>
88
+
<div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div>
89
+
</div>
90
+
{{ end }}
91
+
</div>
92
93
<!-- mobile view (visible only on small screens) -->
94
<div class="md:hidden">
+5
-7
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
+5
-7
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
···
13
</span>
14
</div>
15
16
-
<div class="flex-shrink-0 flex items-center">
17
{{ $latestRound := .LastRoundNumber }}
18
{{ $lastSubmission := index .Submissions $latestRound }}
19
{{ $commentCount := len $lastSubmission.Comments }}
20
{{ if and $pipeline $pipeline.Id }}
21
-
<div class="inline-flex items-center gap-2">
22
-
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
23
-
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
24
-
</div>
25
{{ end }}
26
<span>
27
-
<div class="inline-flex items-center gap-2">
28
{{ i "message-square" "w-3 h-3 md:hidden" }}
29
{{ $commentCount }}
30
<span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span>
31
</div>
32
</span>
33
-
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
34
<span>
35
<span class="hidden md:inline">round</span>
36
<span class="font-mono">#{{ $latestRound }}</span>
···
13
</span>
14
</div>
15
16
+
<div class="flex-shrink-0 flex items-center gap-2">
17
{{ $latestRound := .LastRoundNumber }}
18
{{ $lastSubmission := index .Submissions $latestRound }}
19
{{ $commentCount := len $lastSubmission.Comments }}
20
{{ if and $pipeline $pipeline.Id }}
21
+
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
22
+
<span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span>
23
{{ end }}
24
<span>
25
+
<div class="inline-flex items-center gap-1">
26
{{ i "message-square" "w-3 h-3 md:hidden" }}
27
{{ $commentCount }}
28
<span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span>
29
</div>
30
</span>
31
+
<span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span>
32
<span>
33
<span class="hidden md:inline">round</span>
34
<span class="font-mono">#{{ $latestRound }}</span>
+22
-14
appview/pages/templates/repo/pulls/interdiff.html
+22
-14
appview/pages/templates/repo/pulls/interdiff.html
···
29
{{ end }}
30
31
{{ define "topbarLayout" }}
32
-
{{ template "layouts/topbar" . }}
33
{{ end }}
34
35
-
{{ define "contentLayout" }}
36
-
{{ block "content" . }}{{ end }}
37
-
{{ end }}
38
39
-
{{ define "contentAfterLayout" }}
40
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
41
-
<div class="col-span-1 md:col-span-2">
42
-
{{ block "contentAfterLeft" . }} {{ end }}
43
</div>
44
-
<main class="col-span-1 md:col-span-10">
45
-
{{ block "contentAfter" . }}{{ end }}
46
-
</main>
47
</div>
48
{{ end }}
49
50
-
{{ define "footerLayout" }}
51
-
{{ template "layouts/footer" . }}
52
{{ end }}
53
54
···
60
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
61
{{ template "repo/fragments/diffOpts" .DiffOpts }}
62
</div>
63
-
<div class="sticky top-0 mt-4">
64
{{ template "repo/fragments/interdiffFiles" .Interdiff }}
65
</div>
66
{{end}}
···
29
{{ end }}
30
31
{{ define "topbarLayout" }}
32
+
<header class="px-1 col-span-full" style="z-index: 20;">
33
+
{{ template "layouts/topbar" . }}
34
+
</header>
35
{{ end }}
36
37
+
{{ define "mainLayout" }}
38
+
<div class="px-1 col-span-full flex flex-col gap-4">
39
+
{{ block "contentLayout" . }}
40
+
{{ block "content" . }}{{ end }}
41
+
{{ end }}
42
43
+
{{ block "contentAfterLayout" . }}
44
+
<div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4">
45
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
46
+
{{ block "contentAfterLeft" . }} {{ end }}
47
+
</div>
48
+
<main class="col-span-1 md:col-span-10">
49
+
{{ block "contentAfter" . }}{{ end }}
50
+
</main>
51
</div>
52
+
{{ end }}
53
</div>
54
{{ end }}
55
56
+
{{ define "footerLayout" }}
57
+
<footer class="px-1 col-span-full mt-12">
58
+
{{ template "layouts/footer" . }}
59
+
</footer>
60
{{ end }}
61
62
···
68
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
69
{{ template "repo/fragments/diffOpts" .DiffOpts }}
70
</div>
71
+
<div class="sticky top-0 flex-grow max-h-screen">
72
{{ template "repo/fragments/interdiffFiles" .Interdiff }}
73
</div>
74
{{end}}
+22
-14
appview/pages/templates/repo/pulls/patch.html
+22
-14
appview/pages/templates/repo/pulls/patch.html
···
35
{{ end }}
36
37
{{ define "topbarLayout" }}
38
-
{{ template "layouts/topbar" . }}
39
{{ end }}
40
41
-
{{ define "contentLayout" }}
42
-
{{ block "content" . }}{{ end }}
43
-
{{ end }}
44
45
-
{{ define "contentAfterLayout" }}
46
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
47
-
<div class="col-span-1 md:col-span-2">
48
-
{{ block "contentAfterLeft" . }} {{ end }}
49
</div>
50
-
<main class="col-span-1 md:col-span-10">
51
-
{{ block "contentAfter" . }}{{ end }}
52
-
</main>
53
</div>
54
{{ end }}
55
56
-
{{ define "footerLayout" }}
57
-
{{ template "layouts/footer" . }}
58
{{ end }}
59
60
{{ define "contentAfter" }}
···
65
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
66
{{ template "repo/fragments/diffOpts" .DiffOpts }}
67
</div>
68
-
<div class="sticky top-0 mt-4">
69
{{ template "repo/fragments/diffChangedFiles" .Diff }}
70
</div>
71
{{end}}
···
35
{{ end }}
36
37
{{ define "topbarLayout" }}
38
+
<header class="px-1 col-span-full" style="z-index: 20;">
39
+
{{ template "layouts/topbar" . }}
40
+
</header>
41
{{ end }}
42
43
+
{{ define "mainLayout" }}
44
+
<div class="px-1 col-span-full flex flex-col gap-4">
45
+
{{ block "contentLayout" . }}
46
+
{{ block "content" . }}{{ end }}
47
+
{{ end }}
48
49
+
{{ block "contentAfterLayout" . }}
50
+
<div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4">
51
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
52
+
{{ block "contentAfterLeft" . }} {{ end }}
53
+
</div>
54
+
<main class="col-span-1 md:col-span-10">
55
+
{{ block "contentAfter" . }}{{ end }}
56
+
</main>
57
</div>
58
+
{{ end }}
59
</div>
60
{{ end }}
61
62
+
{{ define "footerLayout" }}
63
+
<footer class="px-1 col-span-full mt-12">
64
+
{{ template "layouts/footer" . }}
65
+
</footer>
66
{{ end }}
67
68
{{ define "contentAfter" }}
···
73
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
74
{{ template "repo/fragments/diffOpts" .DiffOpts }}
75
</div>
76
+
<div class="sticky top-0 flex-grow max-h-screen">
77
{{ template "repo/fragments/diffChangedFiles" .Diff }}
78
</div>
79
{{end}}
+43
-52
appview/pages/templates/repo/pulls/pulls.html
+43
-52
appview/pages/templates/repo/pulls/pulls.html
···
54
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
55
</a>
56
</div>
57
-
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
58
{{ $owner := index $.DidHandleMap .OwnerDid }}
59
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
60
{{ $icon := "ban" }}
···
83
{{ template "repo/fragments/time" .Created }}
84
</span>
85
86
<span class="before:content-['ยท']">
87
-
targeting
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
-
{{ .TargetBranch }}
90
-
</span>
91
</span>
92
-
{{ if not .IsPatchBased }}
93
-
from
94
-
<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">
95
-
{{ if .IsForkBased }}
96
-
{{ if .PullSource.Repo }}
97
-
<a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>:
98
-
{{- else -}}
99
-
<span class="italic">[deleted fork]</span>
100
-
{{- end -}}
101
-
{{- end -}}
102
-
{{- .PullSource.Branch -}}
103
</span>
104
{{ end }}
105
-
<span class="before:content-['ยท']">
106
-
{{ $latestRound := .LastRoundNumber }}
107
-
{{ $lastSubmission := index .Submissions $latestRound }}
108
-
round
109
-
<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">
110
-
#{{ .LastRoundNumber }}
111
-
</span>
112
-
{{ $commentCount := len $lastSubmission.Comments }}
113
-
{{ $s := "s" }}
114
-
{{ if eq $commentCount 1 }}
115
-
{{ $s = "" }}
116
-
{{ end }}
117
-
118
-
{{ if eq $commentCount 0 }}
119
-
awaiting comments
120
-
{{ else }}
121
-
recieved {{ len $lastSubmission.Comments}} comment{{$s}}
122
-
{{ end }}
123
-
</span>
124
-
</p>
125
</div>
126
{{ if .StackId }}
127
{{ $otherPulls := index $.Stacks .StackId }}
128
-
<details class="bg-white dark:bg-gray-800 group">
129
-
<summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
130
-
{{ $s := "s" }}
131
-
{{ if eq (len $otherPulls) 1 }}
132
-
{{ $s = "" }}
133
-
{{ end }}
134
-
<div class="group-open:hidden flex items-center gap-2">
135
-
{{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack
136
-
</div>
137
-
<div class="hidden group-open:flex items-center gap-2">
138
-
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
139
-
</div>
140
-
</summary>
141
-
{{ block "pullList" (list $otherPulls $) }} {{ end }}
142
-
</details>
143
{{ end }}
144
</div>
145
{{ end }}
···
151
{{ $root := index . 1 }}
152
<div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
153
{{ range $pull := $list }}
154
<a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
155
<div class="flex gap-2 items-center px-6">
156
<div class="flex-grow min-w-0 w-full py-2">
157
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull 0) }}
158
</div>
159
</div>
160
</a>
···
54
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
55
</a>
56
</div>
57
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
58
{{ $owner := index $.DidHandleMap .OwnerDid }}
59
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
60
{{ $icon := "ban" }}
···
83
{{ template "repo/fragments/time" .Created }}
84
</span>
85
86
+
87
+
{{ $latestRound := .LastRoundNumber }}
88
+
{{ $lastSubmission := index .Submissions $latestRound }}
89
+
90
<span class="before:content-['ยท']">
91
+
{{ $commentCount := len $lastSubmission.Comments }}
92
+
{{ $s := "s" }}
93
+
{{ if eq $commentCount 1 }}
94
+
{{ $s = "" }}
95
+
{{ end }}
96
+
97
+
{{ len $lastSubmission.Comments}} comment{{$s}}
98
</span>
99
+
100
+
<span class="before:content-['ยท']">
101
+
round
102
+
<span class="font-mono">
103
+
#{{ .LastRoundNumber }}
104
+
</span>
105
</span>
106
+
107
+
{{ $pipeline := index $.Pipelines .LatestSha }}
108
+
{{ if and $pipeline $pipeline.Id }}
109
+
<span class="before:content-['ยท']"></span>
110
+
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
111
{{ end }}
112
+
</div>
113
</div>
114
{{ if .StackId }}
115
{{ $otherPulls := index $.Stacks .StackId }}
116
+
{{ if gt (len $otherPulls) 0 }}
117
+
<details class="bg-white dark:bg-gray-800 group">
118
+
<summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
119
+
{{ $s := "s" }}
120
+
{{ if eq (len $otherPulls) 1 }}
121
+
{{ $s = "" }}
122
+
{{ end }}
123
+
<div class="group-open:hidden flex items-center gap-2">
124
+
{{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack
125
+
</div>
126
+
<div class="hidden group-open:flex items-center gap-2">
127
+
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
128
+
</div>
129
+
</summary>
130
+
{{ block "pullList" (list $otherPulls $) }} {{ end }}
131
+
</details>
132
+
{{ end }}
133
{{ end }}
134
</div>
135
{{ end }}
···
141
{{ $root := index . 1 }}
142
<div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
143
{{ range $pull := $list }}
144
+
{{ $pipeline := index $root.Pipelines $pull.LatestSha }}
145
<a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
146
<div class="flex gap-2 items-center px-6">
147
<div class="flex-grow min-w-0 w-full py-2">
148
+
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
149
</div>
150
</div>
151
</a>
+110
appview/pages/templates/repo/settings/access.html
+110
appview/pages/templates/repo/settings/access.html
···
···
1
+
{{ define "title" }}{{ .Tab }} settings · {{ .RepoInfo.FullName }}{{ end }}
2
+
3
+
{{ define "repoContent" }}
4
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2">
5
+
<div class="col-span-1">
6
+
{{ template "repo/settings/fragments/sidebar" . }}
7
+
</div>
8
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
+
{{ template "collaboratorSettings" . }}
10
+
</div>
11
+
</section>
12
+
{{ end }}
13
+
14
+
{{ define "collaboratorSettings" }}
15
+
<div class="grid grid-cols-1 gap-4 items-center">
16
+
<div class="col-span-1">
17
+
<h2 class="text-sm pb-2 uppercase font-bold">Collaborators</h2>
18
+
<p class="text-gray-500 dark:text-gray-400">
19
+
Any user added as a collaborator will be able to push commits and tags to this repository, upload releases, and workflows.
20
+
</p>
21
+
</div>
22
+
{{ template "collaboratorsGrid" . }}
23
+
</div>
24
+
{{ end }}
25
+
26
+
{{ define "collaboratorsGrid" }}
27
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
28
+
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
29
+
{{ template "addCollaboratorButton" . }}
30
+
{{ end }}
31
+
{{ range .Collaborators }}
32
+
<div class="border border-gray-200 dark:border-gray-700 rounded p-4">
33
+
<div class="flex items-center gap-3">
34
+
<img
35
+
src="{{ fullAvatar .Handle }}"
36
+
alt="{{ .Handle }}"
37
+
class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/>
38
+
39
+
<div class="flex-1 min-w-0">
40
+
<a href="/{{ .Handle }}" class="block truncate">
41
+
{{ didOrHandle .Did .Handle }}
42
+
</a>
43
+
<p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p>
44
+
</div>
45
+
</div>
46
+
</div>
47
+
{{ end }}
48
+
</div>
49
+
{{ end }}
50
+
51
+
{{ define "addCollaboratorButton" }}
52
+
<button
53
+
class="btn block rounded p-4"
54
+
popovertarget="add-collaborator-modal"
55
+
popovertargetaction="toggle">
56
+
<div class="flex items-center gap-3">
57
+
<div class="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
58
+
{{ i "user-plus" "size-4" }}
59
+
</div>
60
+
61
+
<div class="text-left flex-1 min-w-0 block truncate">
62
+
Add collaborator
63
+
</div>
64
+
</div>
65
+
</button>
66
+
<div
67
+
id="add-collaborator-modal"
68
+
popover
69
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
70
+
{{ template "addCollaboratorModal" . }}
71
+
</div>
72
+
{{ end }}
73
+
74
+
{{ define "addCollaboratorModal" }}
75
+
<form
76
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
77
+
hx-indicator="#spinner"
78
+
hx-swap="none"
79
+
class="flex flex-col gap-2"
80
+
>
81
+
<label for="add-collaborator" class="uppercase p-0">
82
+
ADD COLLABORATOR
83
+
</label>
84
+
<p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p>
85
+
<input
86
+
type="text"
87
+
id="add-collaborator"
88
+
name="collaborator"
89
+
required
90
+
placeholder="@foo.bsky.social"
91
+
/>
92
+
<div class="flex gap-2 pt-2">
93
+
<button
94
+
type="button"
95
+
popovertarget="add-collaborator-modal"
96
+
popovertargetaction="hide"
97
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
98
+
>
99
+
{{ i "x" "size-4" }} cancel
100
+
</button>
101
+
<button type="submit" class="btn w-1/2 flex items-center">
102
+
<span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span>
103
+
<span id="spinner" class="group">
104
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
105
+
</span>
106
+
</button>
107
+
</div>
108
+
<div id="add-collaborator-error" class="text-red-500 dark:text-red-400"></div>
109
+
</form>
110
+
{{ end }}
+29
appview/pages/templates/repo/settings/fragments/secretListing.html
+29
appview/pages/templates/repo/settings/fragments/secretListing.html
···
···
1
+
{{ define "repo/settings/fragments/secretListing" }}
2
+
{{ $root := index . 0 }}
3
+
{{ $secret := index . 1 }}
4
+
<div id="secret-{{$secret.Key}}" class="flex items-center justify-between p-2">
5
+
<div class="hover:no-underline flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
6
+
<span class="font-mono">
7
+
{{ $secret.Key }}
8
+
</span>
9
+
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
10
+
<span>added by</span>
11
+
<span>{{ template "user/fragments/picHandleLink" $secret.CreatedBy }}</span>
12
+
<span class="before:content-['ยท'] before:select-none"></span>
13
+
<span>{{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }}</span>
14
+
</div>
15
+
</div>
16
+
<button
17
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
18
+
title="Delete secret"
19
+
hx-delete="/{{ $root.RepoInfo.FullName }}/settings/secrets"
20
+
hx-swap="none"
21
+
hx-vals='{"key": "{{ $secret.Key }}"}'
22
+
hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?"
23
+
>
24
+
{{ i "trash-2" "w-5 h-5" }}
25
+
<span class="hidden md:inline">delete</span>
26
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
27
+
</button>
28
+
</div>
29
+
{{ end }}
+68
appview/pages/templates/repo/settings/general.html
+68
appview/pages/templates/repo/settings/general.html
···
···
1
+
{{ define "title" }}{{ .Tab }} settings · {{ .RepoInfo.FullName }}{{ end }}
2
+
3
+
{{ define "repoContent" }}
4
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2">
5
+
<div class="col-span-1">
6
+
{{ template "repo/settings/fragments/sidebar" . }}
7
+
</div>
8
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
+
{{ template "branchSettings" . }}
10
+
{{ template "deleteRepo" . }}
11
+
</div>
12
+
</section>
13
+
{{ end }}
14
+
15
+
{{ define "branchSettings" }}
16
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
17
+
<div class="col-span-1 md:col-span-2">
18
+
<h2 class="text-sm pb-2 uppercase font-bold">Default Branch</h2>
19
+
<p class="text-gray-500 dark:text-gray-400">
20
+
The default branch is considered the โbaseโ branch in your repository,
21
+
against which all pull requests and code commits are automatically made,
22
+
unless you specify a different branch.
23
+
</p>
24
+
</div>
25
+
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
26
+
<select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
27
+
<option value="" disabled selected >
28
+
Choose a default branch
29
+
</option>
30
+
{{ range .Branches }}
31
+
<option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} >
32
+
{{ .Name }}
33
+
</option>
34
+
{{ end }}
35
+
</select>
36
+
<button class="btn flex gap-2 items-center" type="submit">
37
+
{{ i "check" "size-4" }}
38
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
39
+
</button>
40
+
</form>
41
+
</div>
42
+
{{ end }}
43
+
44
+
{{ define "deleteRepo" }}
45
+
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
46
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
47
+
<div class="col-span-1 md:col-span-2">
48
+
<h2 class="text-sm pb-2 uppercase text-red-500 dark:text-red-400 font-bold">Delete Repository</h2>
49
+
<p class="text-red-500 dark:text-red-400 ">
50
+
Deleting a repository is irreversible and permanent. Be certain before deleting a repository.
51
+
</p>
52
+
</div>
53
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
54
+
<button
55
+
class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center"
56
+
type="button"
57
+
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
58
+
hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?">
59
+
{{ i "trash-2" "size-4" }}
60
+
delete
61
+
<span class="ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline">
62
+
{{ i "loader-circle" "w-4 h-4" }}
63
+
</span>
64
+
</button>
65
+
</div>
66
+
</div>
67
+
{{ end }}
68
+
{{ end }}
+140
appview/pages/templates/repo/settings/pipelines.html
+140
appview/pages/templates/repo/settings/pipelines.html
···
···
1
+
{{ define "title" }}{{ .Tab }} settings · {{ .RepoInfo.FullName }}{{ end }}
2
+
3
+
{{ define "repoContent" }}
4
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2">
5
+
<div class="col-span-1">
6
+
{{ template "repo/settings/fragments/sidebar" . }}
7
+
</div>
8
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
+
{{ template "spindleSettings" . }}
10
+
{{ if $.CurrentSpindle }}
11
+
{{ template "secretSettings" . }}
12
+
{{ end }}
13
+
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
14
+
</div>
15
+
</section>
16
+
{{ end }}
17
+
18
+
{{ define "spindleSettings" }}
19
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
20
+
<div class="col-span-1 md:col-span-2">
21
+
<h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2>
22
+
<p class="text-gray-500 dark:text-gray-400">
23
+
Choose a spindle to execute your workflows on. Only repository owners
24
+
can configure spindles. Spindles can be selfhosted,
25
+
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
26
+
click to learn more.
27
+
</a>
28
+
</p>
29
+
</div>
30
+
{{ if not $.RepoInfo.Roles.IsOwner }}
31
+
<div class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
32
+
{{ or $.CurrentSpindle "No spindle configured" }}
33
+
</div>
34
+
{{ else }}
35
+
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
36
+
<select
37
+
id="spindle"
38
+
name="spindle"
39
+
required
40
+
class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
41
+
<option value="" disabled>
42
+
Choose a spindle
43
+
</option>
44
+
{{ range $.Spindles }}
45
+
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
46
+
{{ . }}
47
+
</option>
48
+
{{ end }}
49
+
</select>
50
+
<button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
51
+
{{ i "check" "size-4" }}
52
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
53
+
</button>
54
+
</form>
55
+
{{ end }}
56
+
</div>
57
+
{{ end }}
58
+
59
+
{{ define "secretSettings" }}
60
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
61
+
<div class="col-span-1 md:col-span-2">
62
+
<h2 class="text-sm pb-2 uppercase font-bold">SECRETS</h2>
63
+
<p class="text-gray-500 dark:text-gray-400">
64
+
Secrets are accessible in workflow runs via environment variables. Anyone
65
+
with collaborator access to this repository can add and use secrets in
66
+
workflow runs.
67
+
</p>
68
+
</div>
69
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
70
+
{{ template "addSecretButton" . }}
71
+
</div>
72
+
</div>
73
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
74
+
{{ range .Secrets }}
75
+
{{ template "repo/settings/fragments/secretListing" (list $ .) }}
76
+
{{ else }}
77
+
<div class="flex items-center justify-center p-2 text-gray-500">
78
+
no secrets added yet
79
+
</div>
80
+
{{ end }}
81
+
</div>
82
+
{{ end }}
83
+
84
+
{{ define "addSecretButton" }}
85
+
<button
86
+
class="btn flex items-center gap-2"
87
+
popovertarget="add-secret-modal"
88
+
popovertargetaction="toggle">
89
+
{{ i "plus" "size-4" }}
90
+
add secret
91
+
</button>
92
+
<div
93
+
id="add-secret-modal"
94
+
popover
95
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
96
+
{{ template "addSecretModal" . }}
97
+
</div>
98
+
{{ end}}
99
+
100
+
{{ define "addSecretModal" }}
101
+
<form
102
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
103
+
hx-indicator="#spinner"
104
+
hx-swap="none"
105
+
class="flex flex-col gap-2"
106
+
>
107
+
<p class="uppercase p-0">ADD SECRET</p>
108
+
<p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p>
109
+
<input
110
+
type="text"
111
+
id="secret-key"
112
+
name="key"
113
+
required
114
+
placeholder="SECRET_NAME"
115
+
/>
116
+
<textarea
117
+
type="text"
118
+
id="secret-value"
119
+
name="value"
120
+
required
121
+
placeholder="secret value"></textarea>
122
+
<div class="flex gap-2 pt-2">
123
+
<button
124
+
type="button"
125
+
popovertarget="add-secret-modal"
126
+
popovertargetaction="hide"
127
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
128
+
>
129
+
{{ i "x" "size-4" }} cancel
130
+
</button>
131
+
<button type="submit" class="btn w-1/2 flex items-center">
132
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
133
+
<span id="spinner" class="group">
134
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
135
+
</span>
136
+
</button>
137
+
</div>
138
+
<div id="add-secret-error" class="text-red-500 dark:text-red-400"></div>
139
+
</form>
140
+
{{ end }}
+150
-120
appview/pages/templates/repo/settings.html
+150
-120
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 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>
21
-
</div>
22
-
{{ end }}
23
-
</div>
24
25
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
26
-
<form
27
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
28
-
class="group"
29
>
30
-
<label for="collaborator" class="dark:text-white">
31
-
add collaborator
32
-
</label>
33
-
<input
34
-
type="text"
35
-
id="collaborator"
36
-
name="collaborator"
37
-
required
38
-
class="dark:bg-gray-700 dark:text-white"
39
-
placeholder="enter did or handle"
40
-
>
41
-
<button
42
-
class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700"
43
-
type="text"
44
-
>
45
-
<span>add</span>
46
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
47
-
</button>
48
-
</form>
49
{{ end }}
50
51
<form
52
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default"
53
-
class="mt-6 group"
54
>
55
-
<label for="branch">default branch</label>
56
-
<div class="flex gap-2 items-center">
57
-
<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">
58
-
<option
59
-
value=""
60
-
disabled
61
-
selected
62
-
>
63
-
Choose a default branch
64
-
</option>
65
-
{{ range .Branches }}
66
-
<option
67
-
value="{{ .Name }}"
68
-
class="py-1"
69
-
{{ if .IsDefault }}
70
-
selected
71
-
{{ end }}
72
-
>
73
-
{{ .Name }}
74
-
</option>
75
-
{{ end }}
76
-
</select>
77
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
78
-
<span>save</span>
79
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
80
-
</button>
81
-
</div>
82
-
</form>
83
-
84
-
{{ if .RepoInfo.Roles.IsOwner }}
85
-
<form
86
-
hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle"
87
-
class="mt-6 group"
88
-
>
89
-
<label for="spindle">spindle</label>
90
-
<div class="flex gap-2 items-center">
91
-
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
92
-
<option
93
-
value=""
94
-
selected
95
-
>
96
-
None
97
-
</option>
98
-
{{ range .Spindles }}
99
-
<option
100
-
value="{{ . }}"
101
-
class="py-1"
102
-
{{ if eq . $.CurrentSpindle }}
103
-
selected
104
-
{{ end }}
105
-
>
106
-
{{ . }}
107
-
</option>
108
-
{{ end }}
109
-
</select>
110
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
111
-
<span>save</span>
112
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
113
-
</button>
114
-
</div>
115
</form>
116
-
{{ end }}
117
118
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
119
<form
120
hx-confirm="Are you sure you want to delete this repository?"
121
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
122
class="mt-6"
123
-
hx-indicator="#delete-repo-spinner"
124
-
>
125
-
<label for="branch">delete repository</label>
126
-
<button class="btn my-2 flex items-center" type="text">
127
-
<span>delete</span>
128
-
<span id="delete-repo-spinner" class="group">
129
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
130
-
</span>
131
-
</button>
132
-
<span>
133
-
Deleting a repository is irreversible and permanent.
134
-
</span>
135
</form>
136
-
{{ end }}
137
138
{{ end }}
···
1
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
+
3
{{ define "repoContent" }}
4
+
{{ template "collaboratorSettings" . }}
5
+
{{ template "branchSettings" . }}
6
+
{{ template "dangerZone" . }}
7
+
{{ template "spindleSelector" . }}
8
+
{{ template "spindleSecrets" . }}
9
+
{{ end }}
10
11
+
{{ define "collaboratorSettings" }}
12
+
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
13
+
Collaborators
14
+
</header>
15
16
+
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
17
+
{{ range .Collaborators }}
18
+
<div id="collaborator" class="mb-2">
19
+
<a
20
+
href="/{{ didOrHandle .Did .Handle }}"
21
+
class="no-underline hover:underline text-black dark:text-white"
22
>
23
+
{{ didOrHandle .Did .Handle }}
24
+
</a>
25
+
<div>
26
+
<span class="text-sm text-gray-500 dark:text-gray-400">
27
+
{{ .Role }}
28
+
</span>
29
+
</div>
30
+
</div>
31
{{ end }}
32
+
</div>
33
34
+
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
35
<form
36
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
37
+
class="group"
38
>
39
+
<label for="collaborator" class="dark:text-white">
40
+
add collaborator
41
+
</label>
42
+
<input
43
+
type="text"
44
+
id="collaborator"
45
+
name="collaborator"
46
+
required
47
+
class="dark:bg-gray-700 dark:text-white"
48
+
placeholder="enter did or handle">
49
+
<button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text">
50
+
<span>add</span>
51
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
52
+
</button>
53
</form>
54
+
{{ end }}
55
+
{{ end }}
56
57
+
{{ define "dangerZone" }}
58
+
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
59
<form
60
hx-confirm="Are you sure you want to delete this repository?"
61
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
62
class="mt-6"
63
+
hx-indicator="#delete-repo-spinner">
64
+
<label for="branch">delete repository</label>
65
+
<button class="btn my-2 flex items-center" type="text">
66
+
<span>delete</span>
67
+
<span id="delete-repo-spinner" class="group">
68
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
69
+
</span>
70
+
</button>
71
+
<span>
72
+
Deleting a repository is irreversible and permanent.
73
+
</span>
74
</form>
75
+
{{ end }}
76
+
{{ end }}
77
+
78
+
{{ define "branchSettings" }}
79
+
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group">
80
+
<label for="branch">default branch</label>
81
+
<div class="flex gap-2 items-center">
82
+
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
83
+
<option value="" disabled selected >
84
+
Choose a default branch
85
+
</option>
86
+
{{ range .Branches }}
87
+
<option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} >
88
+
{{ .Name }}
89
+
</option>
90
+
{{ end }}
91
+
</select>
92
+
<button class="btn my-2 flex gap-2 items-center" type="submit">
93
+
<span>save</span>
94
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
95
+
</button>
96
+
</div>
97
+
</form>
98
+
{{ end }}
99
+
100
+
{{ define "spindleSelector" }}
101
+
{{ if .RepoInfo.Roles.IsOwner }}
102
+
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" >
103
+
<label for="spindle">spindle</label>
104
+
<div class="flex gap-2 items-center">
105
+
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
106
+
<option value="" selected >
107
+
None
108
+
</option>
109
+
{{ range .Spindles }}
110
+
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
111
+
{{ . }}
112
+
</option>
113
+
{{ end }}
114
+
</select>
115
+
<button class="btn my-2 flex gap-2 items-center" type="submit">
116
+
<span>save</span>
117
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
118
+
</button>
119
+
</div>
120
+
</form>
121
+
{{ end }}
122
+
{{ end }}
123
+
124
+
{{ define "spindleSecrets" }}
125
+
{{ if $.CurrentSpindle }}
126
+
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
127
+
Secrets
128
+
</header>
129
+
130
+
<div id="secret-list" class="flex flex-col gap-2 mb-2">
131
+
{{ range $idx, $secret := .Secrets }}
132
+
{{ with $secret }}
133
+
<div id="secret-{{$idx}}" class="mb-2">
134
+
{{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }}
135
+
</div>
136
+
{{ end }}
137
+
{{ end }}
138
+
</div>
139
+
<form
140
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
141
+
class="mt-6"
142
+
hx-indicator="#add-secret-spinner">
143
+
<label for="key">secret key</label>
144
+
<input
145
+
type="text"
146
+
id="key"
147
+
name="key"
148
+
required
149
+
class="dark:bg-gray-700 dark:text-white"
150
+
placeholder="SECRET_KEY" />
151
+
<label for="value">secret value</label>
152
+
<input
153
+
type="text"
154
+
id="value"
155
+
name="value"
156
+
required
157
+
class="dark:bg-gray-700 dark:text-white"
158
+
placeholder="SECRET VALUE" />
159
160
+
<button class="btn my-2 flex items-center" type="text">
161
+
<span>add</span>
162
+
<span id="add-secret-spinner" class="group">
163
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
164
+
</span>
165
+
</button>
166
+
</form>
167
+
{{ end }}
168
{{ end }}
+26
-34
appview/pages/templates/repo/tree.html
+26
-34
appview/pages/templates/repo/tree.html
···
19
{{define "repoContent"}}
20
<main>
21
<div class="tree">
22
-
{{ $containerstyle := "py-1" }}
23
{{ $linkstyle := "no-underline hover:underline" }}
24
25
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
···
54
</div>
55
56
{{ range .Files }}
57
-
{{ if not .IsFile }}
58
-
<div class="{{ $containerstyle }}">
59
-
<div class="flex justify-between items-center">
60
-
<a href="/{{ $.BaseTreeLink }}/{{ .Name }}" class="{{ $linkstyle }}">
61
-
<div class="flex items-center gap-2">
62
-
{{ i "folder" "size-4 fill-current" }}{{ .Name }}
63
-
</div>
64
-
</a>
65
-
{{ if .LastCommit}}
66
-
<div class="flex items-end gap-2">
67
-
<span class="text text-gray-500 dark:text-gray-400 mr-6">{{ .LastCommit.Message }}</span>
68
-
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span>
69
</div>
70
-
{{ end }}
71
</div>
72
-
</div>
73
-
{{ end }}
74
-
{{ end }}
75
76
-
{{ range .Files }}
77
-
{{ if .IsFile }}
78
-
<div class="{{ $containerstyle }}">
79
-
<div class="flex justify-between items-center">
80
-
<a href="/{{ $.BaseBlobLink }}/{{ .Name }}" class="{{ $linkstyle }}">
81
-
<div class="flex items-center gap-2">
82
-
{{ i "file" "size-4" }}{{ .Name }}
83
-
</div>
84
-
</a>
85
-
{{ if .LastCommit}}
86
-
<div class="flex items-end gap-2">
87
-
<span class="text text-gray-500 dark:text-gray-400 mr-6">{{ .LastCommit.Message }}</span>
88
-
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span>
89
-
</div>
90
-
{{ end }}
91
</div>
92
-
</div>
93
{{ end }}
94
-
{{ end }}
95
</div>
96
</main>
97
{{end}}
···
19
{{define "repoContent"}}
20
<main>
21
<div class="tree">
22
{{ $linkstyle := "no-underline hover:underline" }}
23
24
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
···
53
</div>
54
55
{{ range .Files }}
56
+
<div class="grid grid-cols-12 gap-4 items-center py-1">
57
+
<div class="col-span-6 md:col-span-3">
58
+
{{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }}
59
+
{{ $icon := "folder" }}
60
+
{{ $iconStyle := "size-4 fill-current" }}
61
+
62
+
{{ if .IsFile }}
63
+
{{ $icon = "file" }}
64
+
{{ $iconStyle = "size-4" }}
65
+
{{ end }}
66
+
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
+
<div class="flex items-center gap-2">
68
+
{{ i $icon $iconStyle }}{{ .Name }}
69
</div>
70
+
</a>
71
</div>
72
73
+
<div class="col-span-0 md:col-span-7 hidden md:block overflow-hidden">
74
+
{{ with .LastCommit }}
75
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a>
76
+
{{ end }}
77
+
</div>
78
+
79
+
<div class="col-span-6 md:col-span-2 text-right">
80
+
{{ with .LastCommit }}
81
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a>
82
+
{{ end }}
83
</div>
84
+
</div>
85
{{ end }}
86
+
87
</div>
88
</main>
89
{{end}}
+1
-1
appview/pages/templates/spindles/fragments/addMemberModal.html
+1
-1
appview/pages/templates/spindles/fragments/addMemberModal.html
···
13
<div
14
id="add-member-{{ .Instance }}"
15
popover
16
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
17
{{ block "addMemberPopover" . }} {{ end }}
18
</div>
19
{{ end }}
+57
appview/pages/templates/strings/dashboard.html
+57
appview/pages/templates/strings/dashboard.html
···
···
1
+
{{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
+
<meta property="og:type" content="profile" />
6
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
7
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
+
{{ end }}
9
+
10
+
11
+
{{ define "content" }}
12
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
13
+
<div class="md:col-span-3 order-1 md:order-1">
14
+
{{ template "user/fragments/profileCard" .Card }}
15
+
</div>
16
+
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
17
+
{{ block "allStrings" . }}{{ end }}
18
+
</div>
19
+
</div>
20
+
{{ end }}
21
+
22
+
{{ define "allStrings" }}
23
+
<p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p>
24
+
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
25
+
{{ range .Strings }}
26
+
{{ template "singleString" (list $ .) }}
27
+
{{ else }}
28
+
<p class="px-6 dark:text-white">This user does not have any strings yet.</p>
29
+
{{ end }}
30
+
</div>
31
+
{{ end }}
32
+
33
+
{{ define "singleString" }}
34
+
{{ $root := index . 0 }}
35
+
{{ $s := index . 1 }}
36
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
37
+
<div class="font-medium dark:text-white flex gap-2 items-center">
38
+
<a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
39
+
</div>
40
+
{{ with $s.Description }}
41
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
42
+
{{ . }}
43
+
</div>
44
+
{{ end }}
45
+
46
+
{{ $stat := $s.Stats }}
47
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto">
48
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
49
+
<span class="select-none [&:before]:content-['ยท']"></span>
50
+
{{ with $s.Edited }}
51
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
52
+
{{ else }}
53
+
{{ template "repo/fragments/shortTimeAgo" $s.Created }}
54
+
{{ end }}
55
+
</div>
56
+
</div>
57
+
{{ end }}
+89
appview/pages/templates/strings/fragments/form.html
+89
appview/pages/templates/strings/fragments/form.html
···
···
1
+
{{ define "strings/fragments/form" }}
2
+
<form
3
+
{{ if eq .Action "new" }}
4
+
hx-post="/strings/new"
5
+
{{ else }}
6
+
hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit"
7
+
{{ end }}
8
+
hx-indicator="#new-button"
9
+
class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded"
10
+
hx-swap="none">
11
+
<div class="flex flex-col md:flex-row md:items-center gap-2">
12
+
<input
13
+
type="text"
14
+
id="filename"
15
+
name="filename"
16
+
placeholder="Filename with extension"
17
+
required
18
+
value="{{ .String.Filename }}"
19
+
class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
20
+
>
21
+
<input
22
+
type="text"
23
+
id="description"
24
+
name="description"
25
+
value="{{ .String.Description }}"
26
+
placeholder="Description ..."
27
+
class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
28
+
>
29
+
</div>
30
+
<textarea
31
+
name="content"
32
+
id="content-textarea"
33
+
wrap="off"
34
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
35
+
rows="20"
36
+
placeholder="Paste your string here!"
37
+
required>{{ .String.Contents }}</textarea>
38
+
<div class="flex justify-between items-center">
39
+
<div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400">
40
+
<span id="line-count">0 lines</span>
41
+
<span class="select-none px-1 [&:before]:content-['ยท']"></span>
42
+
<span id="byte-count">0 bytes</span>
43
+
</div>
44
+
<div id="actions" class="flex gap-2 items-center">
45
+
{{ if eq .Action "edit" }}
46
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 "
47
+
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}">
48
+
{{ i "x" "size-4" }}
49
+
<span class="hidden md:inline">cancel</span>
50
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
51
+
</a>
52
+
{{ end }}
53
+
<button
54
+
type="submit"
55
+
id="new-button"
56
+
class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group"
57
+
>
58
+
<span class="inline-flex items-center gap-2">
59
+
{{ i "arrow-up" "w-4 h-4" }}
60
+
publish
61
+
</span>
62
+
<span class="pl-2 hidden group-[.htmx-request]:inline">
63
+
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
64
+
</span>
65
+
</button>
66
+
</div>
67
+
</div>
68
+
<script>
69
+
(function() {
70
+
const textarea = document.getElementById('content-textarea');
71
+
const lineCount = document.getElementById('line-count');
72
+
const byteCount = document.getElementById('byte-count');
73
+
function updateStats() {
74
+
const content = textarea.value;
75
+
const lines = content === '' ? 0 : content.split('\n').length;
76
+
const bytes = new TextEncoder().encode(content).length;
77
+
lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`;
78
+
byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`;
79
+
}
80
+
textarea.addEventListener('input', updateStats);
81
+
textarea.addEventListener('paste', () => {
82
+
setTimeout(updateStats, 0);
83
+
});
84
+
updateStats();
85
+
})();
86
+
</script>
87
+
<div id="error" class="error dark:text-red-400"></div>
88
+
</form>
89
+
{{ end }}
+17
appview/pages/templates/strings/put.html
+17
appview/pages/templates/strings/put.html
···
···
1
+
{{ define "title" }}publish a new string{{ end }}
2
+
3
+
{{ define "topbar" }}
4
+
{{ template "layouts/topbar" $ }}
5
+
{{ end }}
6
+
7
+
{{ define "content" }}
8
+
<div class="px-6 py-2 mb-4">
9
+
{{ if eq .Action "new" }}
10
+
<p class="text-xl font-bold dark:text-white">Create a new string</p>
11
+
<p class="">Store and share code snippets with ease.</p>
12
+
{{ else }}
13
+
<p class="text-xl font-bold dark:text-white">Edit string</p>
14
+
{{ end }}
15
+
</div>
16
+
{{ template "strings/fragments/form" . }}
17
+
{{ end }}
+85
appview/pages/templates/strings/string.html
+85
appview/pages/templates/strings/string.html
···
···
1
+
{{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
5
+
<meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" />
6
+
<meta property="og:type" content="object" />
7
+
<meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
8
+
<meta property="og:description" content="{{ .String.Description }}" />
9
+
{{ end }}
10
+
11
+
{{ define "topbar" }}
12
+
{{ template "layouts/topbar" $ }}
13
+
{{ end }}
14
+
15
+
{{ define "content" }}
16
+
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
17
+
<section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
18
+
<div class="text-lg flex items-center justify-between">
19
+
<div>
20
+
<a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a>
21
+
<span class="select-none">/</span>
22
+
<a href="/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
23
+
</div>
24
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
25
+
<div class="flex gap-2 text-base">
26
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
27
+
hx-boost="true"
28
+
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
29
+
{{ i "pencil" "size-4" }}
30
+
<span class="hidden md:inline">edit</span>
31
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
32
+
</a>
33
+
<button
34
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2"
35
+
title="Delete string"
36
+
hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/"
37
+
hx-swap="none"
38
+
hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?"
39
+
>
40
+
{{ i "trash-2" "size-4" }}
41
+
<span class="hidden md:inline">delete</span>
42
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
43
+
</button>
44
+
</div>
45
+
{{ end }}
46
+
</div>
47
+
<span class="flex items-center">
48
+
{{ with .String.Description }}
49
+
{{ . }}
50
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
51
+
{{ end }}
52
+
53
+
{{ with .String.Edited }}
54
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
55
+
{{ else }}
56
+
{{ template "repo/fragments/shortTimeAgo" .String.Created }}
57
+
{{ end }}
58
+
</span>
59
+
</section>
60
+
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
61
+
<div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
62
+
<span>{{ .String.Filename }}</span>
63
+
<div>
64
+
<span>{{ .Stats.LineCount }} lines</span>
65
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
66
+
<span>{{ byteFmt .Stats.ByteCount }}</span>
67
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
68
+
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a>
69
+
{{ if .RenderToggle }}
70
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
71
+
<a href="?code={{ .ShowRendered }}" hx-boost="true">
72
+
view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}
73
+
</a>
74
+
{{ end }}
75
+
</div>
76
+
</div>
77
+
<div class="overflow-auto relative">
78
+
{{ if .ShowRendered }}
79
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
80
+
{{ else }}
81
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
82
+
{{ end }}
83
+
</div>
84
+
</section>
85
+
{{ end }}
+2
-2
appview/pages/templates/timeline.html
+2
-2
appview/pages/templates/timeline.html
+104
appview/pages/templates/user/completeSignup.html
+104
appview/pages/templates/user/completeSignup.html
···
···
1
+
{{ define "user/completeSignup" }}
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
11
+
property="og:title"
12
+
content="complete signup ยท tangled"
13
+
/>
14
+
<meta
15
+
property="og:url"
16
+
content="https://tangled.sh/complete-signup"
17
+
/>
18
+
<meta
19
+
property="og:description"
20
+
content="complete your signup for tangled"
21
+
/>
22
+
<script src="/static/htmx.min.js"></script>
23
+
<link
24
+
rel="stylesheet"
25
+
href="/static/tw.css?{{ cssContentHash }}"
26
+
type="text/css"
27
+
/>
28
+
<title>complete signup · tangled</title>
29
+
</head>
30
+
<body class="flex items-center justify-center min-h-screen">
31
+
<main class="max-w-md px-6 -mt-4">
32
+
<h1
33
+
class="text-center text-2xl font-semibold italic dark:text-white"
34
+
>
35
+
tangled
36
+
</h1>
37
+
<h2 class="text-center text-xl italic dark:text-white">
38
+
tightly-knit social coding.
39
+
</h2>
40
+
<form
41
+
class="mt-4 max-w-sm mx-auto flex flex-col gap-4"
42
+
hx-post="/signup/complete"
43
+
hx-swap="none"
44
+
hx-disabled-elt="#complete-signup-button"
45
+
>
46
+
<div class="flex flex-col">
47
+
<label for="code">verification code</label>
48
+
<input
49
+
type="text"
50
+
id="code"
51
+
name="code"
52
+
tabindex="1"
53
+
required
54
+
placeholder="tngl-sh-foo-bar"
55
+
/>
56
+
<span class="text-sm text-gray-500 mt-1">
57
+
Enter the code sent to your email.
58
+
</span>
59
+
</div>
60
+
61
+
<div class="flex flex-col">
62
+
<label for="username">username</label>
63
+
<input
64
+
type="text"
65
+
id="username"
66
+
name="username"
67
+
tabindex="2"
68
+
required
69
+
placeholder="jason"
70
+
/>
71
+
<span class="text-sm text-gray-500 mt-1">
72
+
Your complete handle will be of the form <code>user.tngl.sh</code>.
73
+
</span>
74
+
</div>
75
+
76
+
<div class="flex flex-col">
77
+
<label for="password">password</label>
78
+
<input
79
+
type="password"
80
+
id="password"
81
+
name="password"
82
+
tabindex="3"
83
+
required
84
+
/>
85
+
<span class="text-sm text-gray-500 mt-1">
86
+
Choose a strong password for your account.
87
+
</span>
88
+
</div>
89
+
90
+
<button
91
+
class="btn-create w-full my-2 mt-6 text-base"
92
+
type="submit"
93
+
id="complete-signup-button"
94
+
tabindex="4"
95
+
>
96
+
<span>complete signup</span>
97
+
</button>
98
+
</form>
99
+
<p id="signup-error" class="error w-full"></p>
100
+
<p id="signup-msg" class="dark:text-white w-full"></p>
101
+
</main>
102
+
</body>
103
+
</html>
104
+
{{ end }}
+1
-3
appview/pages/templates/user/fragments/profileCard.html
+1
-3
appview/pages/templates/user/fragments/profileCard.html
···
2
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
5
-
{{ if .AvatarUri }}
6
<div class="w-3/4 aspect-square relative">
7
-
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" />
8
</div>
9
-
{{ end }}
10
</div>
11
<div class="col-span-2">
12
<p title="{{ didOrHandle .UserDid .UserHandle }}"
···
2
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
5
<div class="w-3/4 aspect-square relative">
6
+
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" />
7
</div>
8
</div>
9
<div class="col-span-2">
10
<p title="{{ didOrHandle .UserDid .UserHandle }}"
+13
-34
appview/pages/templates/user/login.html
+13
-34
appview/pages/templates/user/login.html
···
3
<html lang="en" class="dark:bg-gray-900">
4
<head>
5
<meta charset="UTF-8" />
6
-
<meta
7
-
name="viewport"
8
-
content="width=device-width, initial-scale=1.0"
9
-
/>
10
-
<meta
11
-
property="og:title"
12
-
content="login ยท tangled"
13
-
/>
14
-
<meta
15
-
property="og:url"
16
-
content="https://tangled.sh/login"
17
-
/>
18
-
<meta
19
-
property="og:description"
20
-
content="login to tangled"
21
-
/>
22
<script src="/static/htmx.min.js"></script>
23
-
<link
24
-
rel="stylesheet"
25
-
href="/static/tw.css?{{ cssContentHash }}"
26
-
type="text/css"
27
-
/>
28
<title>login · tangled</title>
29
</head>
30
<body class="flex items-center justify-center min-h-screen">
31
<main class="max-w-md px-6 -mt-4">
32
-
<h1
33
-
class="text-center text-2xl font-semibold italic dark:text-white"
34
-
>
35
tangled
36
</h1>
37
<h2 class="text-center text-xl italic dark:text-white">
···
51
name="handle"
52
tabindex="1"
53
required
54
/>
55
<span class="text-sm text-gray-500 mt-1">
56
-
Use your
57
-
<a href="https://bsky.app">Bluesky</a> handle to log
58
-
in. You will then be redirected to your PDS to
59
-
complete authentication.
60
</span>
61
</div>
62
63
<button
64
-
class="btn w-full my-2 mt-6"
65
type="submit"
66
id="login-button"
67
tabindex="3"
···
70
</button>
71
</form>
72
<p class="text-sm text-gray-500">
73
-
Join our <a href="https://chat.tangled.sh">Discord</a> or
74
-
IRC channel:
75
-
<a href="https://web.libera.chat/#tangled"
76
-
><code>#tangled</code> on Libera Chat</a
77
-
>.
78
</p>
79
<p id="login-msg" class="error w-full"></p>
80
</main>
81
</body>
···
3
<html lang="en" class="dark:bg-gray-900">
4
<head>
5
<meta charset="UTF-8" />
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+
<meta property="og:title" content="login ยท tangled" />
8
+
<meta property="og:url" content="https://tangled.sh/login" />
9
+
<meta property="og:description" content="login to for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
<title>login · tangled</title>
13
</head>
14
<body class="flex items-center justify-center min-h-screen">
15
<main class="max-w-md px-6 -mt-4">
16
+
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >
17
tangled
18
</h1>
19
<h2 class="text-center text-xl italic dark:text-white">
···
33
name="handle"
34
tabindex="1"
35
required
36
+
placeholder="akshay.tngl.sh"
37
/>
38
<span class="text-sm text-gray-500 mt-1">
39
+
Use your <a href="https://atproto.com">ATProto</a>
40
+
handle to log in. If you're unsure, this is likely
41
+
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
42
</span>
43
</div>
44
45
<button
46
+
class="btn w-full my-2 mt-6 text-base "
47
type="submit"
48
id="login-button"
49
tabindex="3"
···
52
</button>
53
</form>
54
<p class="text-sm text-gray-500">
55
+
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
56
</p>
57
+
58
<p id="login-msg" class="error w-full"></p>
59
</main>
60
</body>
+3
-3
appview/pages/templates/user/repos.html
+3
-3
appview/pages/templates/user/repos.html
···
8
{{ end }}
9
10
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-8 gap-4">
12
-
<div class="md:col-span-2 order-1 md:order-1">
13
{{ template "user/fragments/profileCard" .Card }}
14
</div>
15
-
<div id="all-repos" class="md:col-span-6 order-2 md:order-2">
16
{{ block "ownRepos" . }}{{ end }}
17
</div>
18
</div>
···
8
{{ end }}
9
10
{{ define "content" }}
11
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
+
<div class="md:col-span-3 order-1 md:order-1">
13
{{ template "user/fragments/profileCard" .Card }}
14
</div>
15
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
16
{{ block "ownRepos" . }}{{ end }}
17
</div>
18
</div>
+53
appview/pages/templates/user/signup.html
+53
appview/pages/templates/user/signup.html
···
···
1
+
{{ define "user/signup" }}
2
+
<!doctype html>
3
+
<html lang="en" class="dark:bg-gray-900">
4
+
<head>
5
+
<meta charset="UTF-8" />
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+
<meta property="og:title" content="signup ยท tangled" />
8
+
<meta property="og:url" content="https://tangled.sh/signup" />
9
+
<meta property="og:description" content="sign up for tangled" />
10
+
<script src="/static/htmx.min.js"></script>
11
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
+
<title>sign up · tangled</title>
13
+
</head>
14
+
<body class="flex items-center justify-center min-h-screen">
15
+
<main class="max-w-md px-6 -mt-4">
16
+
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1>
17
+
<h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2>
18
+
<form
19
+
class="mt-4 max-w-sm mx-auto"
20
+
hx-post="/signup"
21
+
hx-swap="none"
22
+
hx-disabled-elt="#signup-button"
23
+
>
24
+
<div class="flex flex-col mt-2">
25
+
<label for="email">email</label>
26
+
<input
27
+
type="email"
28
+
id="email"
29
+
name="email"
30
+
tabindex="4"
31
+
required
32
+
placeholder="jason@bourne.co"
33
+
/>
34
+
</div>
35
+
<span class="text-sm text-gray-500 mt-1">
36
+
You will receive an email with an invite code. Enter your
37
+
invite code, desired username, and password in the next
38
+
page to complete your registration.
39
+
</span>
40
+
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
41
+
<span>join now</span>
42
+
</button>
43
+
</form>
44
+
<p class="text-sm text-gray-500">
45
+
Already have an account? <a href="/login" class="underline">Login to Tangled</a>.
46
+
</p>
47
+
48
+
<p id="signup-msg" class="error w-full"></p>
49
+
</main>
50
+
</body>
51
+
</html>
52
+
{{ end }}
53
+
+1
-5
appview/pipelines/pipelines.go
+1
-5
appview/pipelines/pipelines.go
···
11
12
"tangled.sh/tangled.sh/core/appview/config"
13
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/idresolver"
15
"tangled.sh/tangled.sh/core/appview/oauth"
16
"tangled.sh/tangled.sh/core/appview/pages"
17
"tangled.sh/tangled.sh/core/appview/reporesolver"
18
"tangled.sh/tangled.sh/core/eventconsumer"
19
"tangled.sh/tangled.sh/core/log"
20
"tangled.sh/tangled.sh/core/rbac"
21
spindlemodel "tangled.sh/tangled.sh/core/spindle/models"
22
23
"github.com/go-chi/chi/v5"
24
"github.com/gorilla/websocket"
25
-
"github.com/posthog/posthog-go"
26
)
27
28
type Pipelines struct {
···
34
spindlestream *eventconsumer.Consumer
35
db *db.DB
36
enforcer *rbac.Enforcer
37
-
posthog posthog.Client
38
logger *slog.Logger
39
}
40
···
46
idResolver *idresolver.Resolver,
47
db *db.DB,
48
config *config.Config,
49
-
posthog posthog.Client,
50
enforcer *rbac.Enforcer,
51
) *Pipelines {
52
logger := log.New("pipelines")
···
58
config: config,
59
spindlestream: spindlestream,
60
db: db,
61
-
posthog: posthog,
62
enforcer: enforcer,
63
logger: logger,
64
}
···
11
12
"tangled.sh/tangled.sh/core/appview/config"
13
"tangled.sh/tangled.sh/core/appview/db"
14
"tangled.sh/tangled.sh/core/appview/oauth"
15
"tangled.sh/tangled.sh/core/appview/pages"
16
"tangled.sh/tangled.sh/core/appview/reporesolver"
17
"tangled.sh/tangled.sh/core/eventconsumer"
18
+
"tangled.sh/tangled.sh/core/idresolver"
19
"tangled.sh/tangled.sh/core/log"
20
"tangled.sh/tangled.sh/core/rbac"
21
spindlemodel "tangled.sh/tangled.sh/core/spindle/models"
22
23
"github.com/go-chi/chi/v5"
24
"github.com/gorilla/websocket"
25
)
26
27
type Pipelines struct {
···
33
spindlestream *eventconsumer.Consumer
34
db *db.DB
35
enforcer *rbac.Enforcer
36
logger *slog.Logger
37
}
38
···
44
idResolver *idresolver.Resolver,
45
db *db.DB,
46
config *config.Config,
47
enforcer *rbac.Enforcer,
48
) *Pipelines {
49
logger := log.New("pipelines")
···
55
config: config,
56
spindlestream: spindlestream,
57
db: db,
58
enforcer: enforcer,
59
logger: logger,
60
}
+131
appview/posthog/notifier.go
+131
appview/posthog/notifier.go
···
···
1
+
package posthog_service
2
+
3
+
import (
4
+
"context"
5
+
"log"
6
+
7
+
"github.com/posthog/posthog-go"
8
+
"tangled.sh/tangled.sh/core/appview/db"
9
+
"tangled.sh/tangled.sh/core/appview/notify"
10
+
)
11
+
12
+
type posthogNotifier struct {
13
+
client posthog.Client
14
+
notify.BaseNotifier
15
+
}
16
+
17
+
func NewPosthogNotifier(client posthog.Client) notify.Notifier {
18
+
return &posthogNotifier{
19
+
client,
20
+
notify.BaseNotifier{},
21
+
}
22
+
}
23
+
24
+
var _ notify.Notifier = &posthogNotifier{}
25
+
26
+
func (n *posthogNotifier) NewRepo(ctx context.Context, repo *db.Repo) {
27
+
err := n.client.Enqueue(posthog.Capture{
28
+
DistinctId: repo.Did,
29
+
Event: "new_repo",
30
+
Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()},
31
+
})
32
+
if err != nil {
33
+
log.Println("failed to enqueue posthog event:", err)
34
+
}
35
+
}
36
+
37
+
func (n *posthogNotifier) NewStar(ctx context.Context, star *db.Star) {
38
+
err := n.client.Enqueue(posthog.Capture{
39
+
DistinctId: star.StarredByDid,
40
+
Event: "star",
41
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
42
+
})
43
+
if err != nil {
44
+
log.Println("failed to enqueue posthog event:", err)
45
+
}
46
+
}
47
+
48
+
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *db.Star) {
49
+
err := n.client.Enqueue(posthog.Capture{
50
+
DistinctId: star.StarredByDid,
51
+
Event: "unstar",
52
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
53
+
})
54
+
if err != nil {
55
+
log.Println("failed to enqueue posthog event:", err)
56
+
}
57
+
}
58
+
59
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
60
+
err := n.client.Enqueue(posthog.Capture{
61
+
DistinctId: issue.OwnerDid,
62
+
Event: "new_issue",
63
+
Properties: posthog.Properties{
64
+
"repo_at": issue.RepoAt.String(),
65
+
"issue_id": issue.IssueId,
66
+
},
67
+
})
68
+
if err != nil {
69
+
log.Println("failed to enqueue posthog event:", err)
70
+
}
71
+
}
72
+
73
+
func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) {
74
+
err := n.client.Enqueue(posthog.Capture{
75
+
DistinctId: pull.OwnerDid,
76
+
Event: "new_pull",
77
+
Properties: posthog.Properties{
78
+
"repo_at": pull.RepoAt,
79
+
"pull_id": pull.PullId,
80
+
},
81
+
})
82
+
if err != nil {
83
+
log.Println("failed to enqueue posthog event:", err)
84
+
}
85
+
}
86
+
87
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {
88
+
err := n.client.Enqueue(posthog.Capture{
89
+
DistinctId: comment.OwnerDid,
90
+
Event: "new_pull_comment",
91
+
Properties: posthog.Properties{
92
+
"repo_at": comment.RepoAt,
93
+
"pull_id": comment.PullId,
94
+
},
95
+
})
96
+
if err != nil {
97
+
log.Println("failed to enqueue posthog event:", err)
98
+
}
99
+
}
100
+
101
+
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *db.Follow) {
102
+
err := n.client.Enqueue(posthog.Capture{
103
+
DistinctId: follow.UserDid,
104
+
Event: "follow",
105
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
106
+
})
107
+
if err != nil {
108
+
log.Println("failed to enqueue posthog event:", err)
109
+
}
110
+
}
111
+
112
+
func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {
113
+
err := n.client.Enqueue(posthog.Capture{
114
+
DistinctId: follow.UserDid,
115
+
Event: "unfollow",
116
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
117
+
})
118
+
if err != nil {
119
+
log.Println("failed to enqueue posthog event:", err)
120
+
}
121
+
}
122
+
123
+
func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {
124
+
err := n.client.Enqueue(posthog.Capture{
125
+
DistinctId: profile.Did,
126
+
Event: "edit_profile",
127
+
})
128
+
if err != nil {
129
+
log.Println("failed to enqueue posthog event:", err)
130
+
}
131
+
}
+44
-41
appview/pulls/pulls.go
+44
-41
appview/pulls/pulls.go
···
14
"time"
15
16
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/appview"
18
"tangled.sh/tangled.sh/core/appview/config"
19
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
21
"tangled.sh/tangled.sh/core/appview/oauth"
22
"tangled.sh/tangled.sh/core/appview/pages"
23
"tangled.sh/tangled.sh/core/appview/reporesolver"
24
"tangled.sh/tangled.sh/core/knotclient"
25
"tangled.sh/tangled.sh/core/patchutil"
26
"tangled.sh/tangled.sh/core/types"
27
28
"github.com/bluekeyes/go-gitdiff/gitdiff"
···
31
lexutil "github.com/bluesky-social/indigo/lex/util"
32
"github.com/go-chi/chi/v5"
33
"github.com/google/uuid"
34
-
"github.com/posthog/posthog-go"
35
)
36
37
type Pulls struct {
···
41
idResolver *idresolver.Resolver
42
db *db.DB
43
config *config.Config
44
-
posthog posthog.Client
45
}
46
47
func New(
···
51
resolver *idresolver.Resolver,
52
db *db.DB,
53
config *config.Config,
54
-
posthog posthog.Client,
55
) *Pulls {
56
return &Pulls{
57
oauth: oauth,
···
60
idResolver: resolver,
61
db: db,
62
config: config,
63
-
posthog: posthog,
64
}
65
}
66
···
555
556
// we want to group all stacked PRs into just one list
557
stacks := make(map[string]db.Stack)
558
n := 0
559
for _, p := range pulls {
560
// this PR is stacked
561
if p.StackId != "" {
562
// we have already seen this PR stack
···
575
}
576
pulls = pulls[:n]
577
578
identsToResolve := make([]string, len(pulls))
579
for i, pull := range pulls {
580
identsToResolve[i] = pull.OwnerDid
···
596
DidHandleMap: didHandleMap,
597
FilteringBy: state,
598
Stacks: stacks,
599
})
600
-
return
601
}
602
603
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
···
668
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
669
Collection: tangled.RepoPullCommentNSID,
670
Repo: user.Did,
671
-
Rkey: appview.TID(),
672
Record: &lexutil.LexiconTypeDecoder{
673
Val: &tangled.RepoPullComment{
674
Repo: &atUri,
···
685
return
686
}
687
688
-
// Create the pull comment in the database with the commentAt field
689
-
commentId, err := db.NewPullComment(tx, &db.PullComment{
690
OwnerDid: user.Did,
691
RepoAt: f.RepoAt.String(),
692
PullId: pull.PullId,
693
Body: body,
694
CommentAt: atResp.Uri,
695
SubmissionId: pull.Submissions[roundNumber].ID,
696
-
})
697
if err != nil {
698
log.Println("failed to create pull comment", err)
699
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
707
return
708
}
709
710
-
if !s.config.Core.Dev {
711
-
err = s.posthog.Enqueue(posthog.Capture{
712
-
DistinctId: user.Did,
713
-
Event: "new_pull_comment",
714
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId},
715
-
})
716
-
if err != nil {
717
-
log.Println("failed to enqueue posthog event:", err)
718
-
}
719
-
}
720
721
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
722
return
···
1045
body = formatPatches[0].Body
1046
}
1047
1048
-
rkey := appview.TID()
1049
initialSubmission := db.PullSubmission{
1050
Patch: patch,
1051
SourceRev: sourceRev,
1052
}
1053
-
err = db.NewPull(tx, &db.Pull{
1054
Title: title,
1055
Body: body,
1056
TargetBranch: targetBranch,
···
1061
&initialSubmission,
1062
},
1063
PullSource: pullSource,
1064
-
})
1065
if err != nil {
1066
log.Println("failed to create pull request", err)
1067
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
1101
return
1102
}
1103
1104
-
if !s.config.Core.Dev {
1105
-
err = s.posthog.Enqueue(posthog.Capture{
1106
-
DistinctId: user.Did,
1107
-
Event: "new_pull",
1108
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId},
1109
-
})
1110
-
if err != nil {
1111
-
log.Println("failed to enqueue posthog event:", err)
1112
-
}
1113
-
}
1114
1115
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1116
}
···
1673
}
1674
1675
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1676
-
return
1677
}
1678
1679
func (s *Pulls) resubmitStackedPullHelper(
···
1917
}
1918
1919
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1920
-
return
1921
}
1922
1923
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
···
2041
2042
// auth filter: only owner or collaborators can close
2043
roles := f.RolesInRepo(user)
2044
isCollaborator := roles.IsCollaborator()
2045
isPullAuthor := user.Did == pull.OwnerDid
2046
-
isCloseAllowed := isCollaborator || isPullAuthor
2047
if !isCloseAllowed {
2048
log.Println("failed to close pull")
2049
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2087
}
2088
2089
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2090
-
return
2091
}
2092
2093
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···
2109
2110
// auth filter: only owner or collaborators can close
2111
roles := f.RolesInRepo(user)
2112
isCollaborator := roles.IsCollaborator()
2113
isPullAuthor := user.Did == pull.OwnerDid
2114
-
isCloseAllowed := isCollaborator || isPullAuthor
2115
if !isCloseAllowed {
2116
log.Println("failed to close pull")
2117
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2155
}
2156
2157
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2158
-
return
2159
}
2160
2161
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
···
2181
2182
title := fp.Title
2183
body := fp.Body
2184
-
rkey := appview.TID()
2185
2186
initialSubmission := db.PullSubmission{
2187
Patch: fp.Raw,
···
14
"time"
15
16
"tangled.sh/tangled.sh/core/api/tangled"
17
"tangled.sh/tangled.sh/core/appview/config"
18
"tangled.sh/tangled.sh/core/appview/db"
19
+
"tangled.sh/tangled.sh/core/appview/notify"
20
"tangled.sh/tangled.sh/core/appview/oauth"
21
"tangled.sh/tangled.sh/core/appview/pages"
22
"tangled.sh/tangled.sh/core/appview/reporesolver"
23
+
"tangled.sh/tangled.sh/core/idresolver"
24
"tangled.sh/tangled.sh/core/knotclient"
25
"tangled.sh/tangled.sh/core/patchutil"
26
+
"tangled.sh/tangled.sh/core/tid"
27
"tangled.sh/tangled.sh/core/types"
28
29
"github.com/bluekeyes/go-gitdiff/gitdiff"
···
32
lexutil "github.com/bluesky-social/indigo/lex/util"
33
"github.com/go-chi/chi/v5"
34
"github.com/google/uuid"
35
)
36
37
type Pulls struct {
···
41
idResolver *idresolver.Resolver
42
db *db.DB
43
config *config.Config
44
+
notifier notify.Notifier
45
}
46
47
func New(
···
51
resolver *idresolver.Resolver,
52
db *db.DB,
53
config *config.Config,
54
+
notifier notify.Notifier,
55
) *Pulls {
56
return &Pulls{
57
oauth: oauth,
···
60
idResolver: resolver,
61
db: db,
62
config: config,
63
+
notifier: notifier,
64
}
65
}
66
···
555
556
// we want to group all stacked PRs into just one list
557
stacks := make(map[string]db.Stack)
558
+
var shas []string
559
n := 0
560
for _, p := range pulls {
561
+
// store the sha for later
562
+
shas = append(shas, p.LatestSha())
563
// this PR is stacked
564
if p.StackId != "" {
565
// we have already seen this PR stack
···
578
}
579
pulls = pulls[:n]
580
581
+
repoInfo := f.RepoInfo(user)
582
+
ps, err := db.GetPipelineStatuses(
583
+
s.db,
584
+
db.FilterEq("repo_owner", repoInfo.OwnerDid),
585
+
db.FilterEq("repo_name", repoInfo.Name),
586
+
db.FilterEq("knot", repoInfo.Knot),
587
+
db.FilterIn("sha", shas),
588
+
)
589
+
if err != nil {
590
+
log.Printf("failed to fetch pipeline statuses: %s", err)
591
+
// non-fatal
592
+
}
593
+
m := make(map[string]db.Pipeline)
594
+
for _, p := range ps {
595
+
m[p.Sha] = p
596
+
}
597
+
598
identsToResolve := make([]string, len(pulls))
599
for i, pull := range pulls {
600
identsToResolve[i] = pull.OwnerDid
···
616
DidHandleMap: didHandleMap,
617
FilteringBy: state,
618
Stacks: stacks,
619
+
Pipelines: m,
620
})
621
}
622
623
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
···
688
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
689
Collection: tangled.RepoPullCommentNSID,
690
Repo: user.Did,
691
+
Rkey: tid.TID(),
692
Record: &lexutil.LexiconTypeDecoder{
693
Val: &tangled.RepoPullComment{
694
Repo: &atUri,
···
705
return
706
}
707
708
+
comment := &db.PullComment{
709
OwnerDid: user.Did,
710
RepoAt: f.RepoAt.String(),
711
PullId: pull.PullId,
712
Body: body,
713
CommentAt: atResp.Uri,
714
SubmissionId: pull.Submissions[roundNumber].ID,
715
+
}
716
+
717
+
// Create the pull comment in the database with the commentAt field
718
+
commentId, err := db.NewPullComment(tx, comment)
719
if err != nil {
720
log.Println("failed to create pull comment", err)
721
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
729
return
730
}
731
732
+
s.notifier.NewPullComment(r.Context(), comment)
733
734
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
735
return
···
1058
body = formatPatches[0].Body
1059
}
1060
1061
+
rkey := tid.TID()
1062
initialSubmission := db.PullSubmission{
1063
Patch: patch,
1064
SourceRev: sourceRev,
1065
}
1066
+
pull := &db.Pull{
1067
Title: title,
1068
Body: body,
1069
TargetBranch: targetBranch,
···
1074
&initialSubmission,
1075
},
1076
PullSource: pullSource,
1077
+
}
1078
+
err = db.NewPull(tx, pull)
1079
if err != nil {
1080
log.Println("failed to create pull request", err)
1081
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
1115
return
1116
}
1117
1118
+
s.notifier.NewPull(r.Context(), pull)
1119
1120
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1121
}
···
1678
}
1679
1680
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1681
}
1682
1683
func (s *Pulls) resubmitStackedPullHelper(
···
1921
}
1922
1923
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1924
}
1925
1926
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
···
2044
2045
// auth filter: only owner or collaborators can close
2046
roles := f.RolesInRepo(user)
2047
+
isOwner := roles.IsOwner()
2048
isCollaborator := roles.IsCollaborator()
2049
isPullAuthor := user.Did == pull.OwnerDid
2050
+
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2051
if !isCloseAllowed {
2052
log.Println("failed to close pull")
2053
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2091
}
2092
2093
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2094
}
2095
2096
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···
2112
2113
// auth filter: only owner or collaborators can close
2114
roles := f.RolesInRepo(user)
2115
+
isOwner := roles.IsOwner()
2116
isCollaborator := roles.IsCollaborator()
2117
isPullAuthor := user.Did == pull.OwnerDid
2118
+
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2119
if !isCloseAllowed {
2120
log.Println("failed to close pull")
2121
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2159
}
2160
2161
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2162
}
2163
2164
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
···
2184
2185
title := fp.Title
2186
body := fp.Body
2187
+
rkey := tid.TID()
2188
2189
initialSubmission := db.PullSubmission{
2190
Patch: fp.Raw,
+2
appview/pulls/router.go
+2
appview/pulls/router.go
+2
-2
appview/repo/artifact.go
+2
-2
appview/repo/artifact.go
···
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/pages"
20
"tangled.sh/tangled.sh/core/appview/reporesolver"
21
"tangled.sh/tangled.sh/core/knotclient"
22
"tangled.sh/tangled.sh/core/types"
23
)
24
···
64
65
log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String())
66
67
-
rkey := appview.TID()
68
createdAt := time.Now()
69
70
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
···
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/db"
18
"tangled.sh/tangled.sh/core/appview/pages"
19
"tangled.sh/tangled.sh/core/appview/reporesolver"
20
"tangled.sh/tangled.sh/core/knotclient"
21
+
"tangled.sh/tangled.sh/core/tid"
22
"tangled.sh/tangled.sh/core/types"
23
)
24
···
64
65
log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String())
66
67
+
rkey := tid.TID()
68
createdAt := time.Now()
69
70
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+2
appview/repo/index.go
+2
appview/repo/index.go
+413
-124
appview/repo/repo.go
+413
-124
appview/repo/repo.go
···
8
"fmt"
9
"io"
10
"log"
11
"net/http"
12
"net/url"
13
-
"path"
14
"slices"
15
-
"sort"
16
"strconv"
17
"strings"
18
"time"
19
20
"tangled.sh/tangled.sh/core/api/tangled"
21
-
"tangled.sh/tangled.sh/core/appview"
22
"tangled.sh/tangled.sh/core/appview/commitverify"
23
"tangled.sh/tangled.sh/core/appview/config"
24
"tangled.sh/tangled.sh/core/appview/db"
25
-
"tangled.sh/tangled.sh/core/appview/idresolver"
26
"tangled.sh/tangled.sh/core/appview/oauth"
27
"tangled.sh/tangled.sh/core/appview/pages"
28
"tangled.sh/tangled.sh/core/appview/pages/markup"
29
"tangled.sh/tangled.sh/core/appview/reporesolver"
30
"tangled.sh/tangled.sh/core/eventconsumer"
31
"tangled.sh/tangled.sh/core/knotclient"
32
"tangled.sh/tangled.sh/core/patchutil"
33
"tangled.sh/tangled.sh/core/rbac"
34
"tangled.sh/tangled.sh/core/types"
35
36
securejoin "github.com/cyphar/filepath-securejoin"
37
"github.com/go-chi/chi/v5"
38
"github.com/go-git/go-git/v5/plumbing"
39
-
"github.com/posthog/posthog-go"
40
41
comatproto "github.com/bluesky-social/indigo/api/atproto"
42
lexutil "github.com/bluesky-social/indigo/lex/util"
43
)
44
···
51
spindlestream *eventconsumer.Consumer
52
db *db.DB
53
enforcer *rbac.Enforcer
54
-
posthog posthog.Client
55
}
56
57
func New(
···
62
idResolver *idresolver.Resolver,
63
db *db.DB,
64
config *config.Config,
65
-
posthog posthog.Client,
66
enforcer *rbac.Enforcer,
67
) *Repo {
68
return &Repo{oauth: oauth,
69
repoResolver: repoResolver,
···
72
config: config,
73
spindlestream: spindlestream,
74
db: db,
75
-
posthog: posthog,
76
enforcer: enforcer,
77
}
78
}
79
···
179
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
180
RepoInfo: f.RepoInfo(user),
181
})
182
-
return
183
}
184
185
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
···
374
375
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
376
// so we can safely redirect to the "parent" (which is the same file).
377
-
if len(result.Files) == 0 && result.Parent == treePath {
378
http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
379
return
380
}
···
389
}
390
}
391
392
-
baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
393
-
baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
394
395
rp.pages.RepoTree(w, pages.RepoTreeParams{
396
LoggedInUser: user,
397
BreadCrumbs: breadcrumbs,
398
-
BaseTreeLink: baseTreeLink,
399
-
BaseBlobLink: baseBlobLink,
400
RepoInfo: f.RepoInfo(user),
401
RepoTreeResponse: result,
402
})
403
-
return
404
}
405
406
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
···
458
ArtifactMap: artifactMap,
459
DanglingArtifacts: danglingArtifacts,
460
})
461
-
return
462
}
463
464
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
···
480
return
481
}
482
483
-
slices.SortFunc(result.Branches, func(a, b types.Branch) int {
484
-
if a.IsDefault {
485
-
return -1
486
-
}
487
-
if b.IsDefault {
488
-
return 1
489
-
}
490
-
if a.Commit != nil && b.Commit != nil {
491
-
if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
492
-
return 1
493
-
} else {
494
-
return -1
495
-
}
496
-
}
497
-
return strings.Compare(a.Name, b.Name) * -1
498
-
})
499
500
user := rp.oauth.GetUser(r)
501
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
···
503
RepoInfo: f.RepoInfo(user),
504
RepoBranchesResponse: *result,
505
})
506
-
return
507
}
508
509
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
···
554
showRendered = r.URL.Query().Get("code") != "true"
555
}
556
557
user := rp.oauth.GetUser(r)
558
rp.pages.RepoBlob(w, pages.RepoBlobParams{
559
LoggedInUser: user,
···
562
BreadCrumbs: breadcrumbs,
563
ShowRendered: showRendered,
564
RenderToggle: renderToggle,
565
})
566
-
return
567
}
568
569
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
570
f, err := rp.repoResolver.Resolve(r)
571
if err != nil {
572
log.Println("failed to get repo and knot", err)
573
return
574
}
575
···
580
if !rp.config.Core.Dev {
581
protocol = "https"
582
}
583
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
584
if err != nil {
585
-
log.Println("failed to reach knotserver", err)
586
return
587
}
588
589
-
body, err := io.ReadAll(resp.Body)
590
-
if err != nil {
591
-
log.Printf("Error reading response body: %v", err)
592
return
593
}
594
595
-
var result types.RepoBlobResponse
596
-
err = json.Unmarshal(body, &result)
597
if err != nil {
598
-
log.Println("failed to parse response:", err)
599
return
600
}
601
602
-
if result.IsBinary {
603
-
w.Header().Set("Content-Type", "application/octet-stream")
604
w.Write(body)
605
return
606
}
607
-
608
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
609
-
w.Write([]byte(result.Contents))
610
-
return
611
}
612
613
// modify the spindle configured for this repo
614
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
615
f, err := rp.repoResolver.Resolve(r)
616
if err != nil {
617
-
log.Println("failed to get repo and knot", err)
618
-
w.WriteHeader(http.StatusBadRequest)
619
return
620
}
621
622
repoAt := f.RepoAt
623
rkey := repoAt.RecordKey().String()
624
if rkey == "" {
625
-
log.Println("invalid aturi for repo", err)
626
-
w.WriteHeader(http.StatusInternalServerError)
627
return
628
}
629
-
630
-
user := rp.oauth.GetUser(r)
631
632
newSpindle := r.FormValue("spindle")
633
client, err := rp.oauth.AuthorizedClient(r)
634
if err != nil {
635
-
log.Println("failed to get client")
636
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
637
return
638
}
639
640
// ensure that this is a valid spindle for this user
641
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
642
if err != nil {
643
-
log.Println("failed to get valid spindles")
644
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
645
return
646
}
647
648
if !slices.Contains(validSpindles, newSpindle) {
649
-
log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles)
650
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
651
return
652
}
653
654
// optimistic update
655
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
656
if err != nil {
657
-
log.Println("failed to perform update-spindle query", err)
658
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
659
return
660
}
661
662
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
663
if err != nil {
664
-
// failed to get record
665
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.")
666
return
667
}
668
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
···
683
})
684
685
if err != nil {
686
-
log.Println("failed to perform update-spindle query", err)
687
-
// failed to get record
688
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, unable to save to PDS.")
689
return
690
}
691
···
695
eventconsumer.NewSpindleSource(newSpindle),
696
)
697
698
-
w.Write(fmt.Append(nil, "spindle set to: ", newSpindle))
699
}
700
701
func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
702
f, err := rp.repoResolver.Resolve(r)
703
if err != nil {
704
-
log.Println("failed to get repo and knot", err)
705
return
706
}
707
708
collaborator := r.FormValue("collaborator")
709
if collaborator == "" {
710
-
http.Error(w, "malformed form", http.StatusBadRequest)
711
return
712
}
713
714
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
715
if err != nil {
716
-
w.Write([]byte("failed to resolve collaborator did to a handle"))
717
return
718
}
719
-
log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
720
721
-
// TODO: create an atproto record for this
722
723
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
724
if err != nil {
725
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
726
return
727
}
728
729
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
730
if err != nil {
731
-
log.Println("failed to create client to ", f.Knot)
732
return
733
}
734
735
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
736
if err != nil {
737
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
738
return
739
}
740
741
if ksResp.StatusCode != http.StatusNoContent {
742
-
w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err))
743
return
744
}
745
746
tx, err := rp.db.BeginTx(r.Context(), nil)
747
if err != nil {
748
-
log.Println("failed to start tx")
749
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
750
return
751
}
752
defer func() {
753
tx.Rollback()
754
err = rp.enforcer.E.LoadPolicy()
755
if err != nil {
756
-
log.Println("failed to rollback policies")
757
}
758
}()
759
760
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
761
if err != nil {
762
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
763
return
764
}
765
766
-
err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
767
if err != nil {
768
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
769
return
770
}
771
772
err = tx.Commit()
773
if err != nil {
774
-
log.Println("failed to commit changes", err)
775
-
http.Error(w, err.Error(), http.StatusInternalServerError)
776
return
777
}
778
779
err = rp.enforcer.E.SavePolicy()
780
if err != nil {
781
-
log.Println("failed to update ACLs", err)
782
-
http.Error(w, err.Error(), http.StatusInternalServerError)
783
return
784
}
785
786
-
w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String()))
787
-
788
}
789
790
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
···
936
w.Write(fmt.Append(nil, "default branch set to: ", branch))
937
}
938
939
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
940
f, err := rp.repoResolver.Resolve(r)
941
if err != nil {
942
log.Println("failed to get repo and knot", err)
943
return
944
}
945
946
switch r.Method {
947
-
case http.MethodGet:
948
-
// for now, this is just pubkeys
949
-
user := rp.oauth.GetUser(r)
950
-
repoCollaborators, err := f.Collaborators(r.Context())
951
-
if err != nil {
952
-
log.Println("failed to get collaborators", err)
953
-
}
954
955
-
isCollaboratorInviteAllowed := false
956
-
if user != nil {
957
-
ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
958
-
if err == nil && ok {
959
-
isCollaboratorInviteAllowed = true
960
-
}
961
}
962
963
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
964
if err != nil {
965
-
log.Println("failed to create unsigned client", err)
966
return
967
}
968
969
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
970
if err != nil {
971
-
log.Println("failed to reach knotserver", err)
972
return
973
}
974
975
-
// all spindles that this user is a member of
976
-
spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
977
-
if err != nil {
978
-
log.Println("failed to fetch spindles", err)
979
-
return
980
}
981
982
-
rp.pages.RepoSettings(w, pages.RepoSettingsParams{
983
-
LoggedInUser: user,
984
-
RepoInfo: f.RepoInfo(user),
985
-
Collaborators: repoCollaborators,
986
-
IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
987
-
Branches: result.Branches,
988
-
Spindles: spindles,
989
-
CurrentSpindle: f.Spindle,
990
})
991
}
992
}
993
994
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
···
1108
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1109
sourceAt := f.RepoAt.String()
1110
1111
-
rkey := appview.TID()
1112
repo := &db.Repo{
1113
Did: user.Did,
1114
Name: forkName,
···
1233
return
1234
}
1235
branches := result.Branches
1236
-
sort.Slice(branches, func(i int, j int) bool {
1237
-
return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
1238
-
})
1239
1240
var defaultBranch string
1241
for _, b := range branches {
···
8
"fmt"
9
"io"
10
"log"
11
+
"log/slog"
12
"net/http"
13
"net/url"
14
+
"path/filepath"
15
"slices"
16
"strconv"
17
"strings"
18
"time"
19
20
"tangled.sh/tangled.sh/core/api/tangled"
21
"tangled.sh/tangled.sh/core/appview/commitverify"
22
"tangled.sh/tangled.sh/core/appview/config"
23
"tangled.sh/tangled.sh/core/appview/db"
24
+
"tangled.sh/tangled.sh/core/appview/notify"
25
"tangled.sh/tangled.sh/core/appview/oauth"
26
"tangled.sh/tangled.sh/core/appview/pages"
27
"tangled.sh/tangled.sh/core/appview/pages/markup"
28
"tangled.sh/tangled.sh/core/appview/reporesolver"
29
"tangled.sh/tangled.sh/core/eventconsumer"
30
+
"tangled.sh/tangled.sh/core/idresolver"
31
"tangled.sh/tangled.sh/core/knotclient"
32
"tangled.sh/tangled.sh/core/patchutil"
33
"tangled.sh/tangled.sh/core/rbac"
34
+
"tangled.sh/tangled.sh/core/tid"
35
"tangled.sh/tangled.sh/core/types"
36
37
securejoin "github.com/cyphar/filepath-securejoin"
38
"github.com/go-chi/chi/v5"
39
"github.com/go-git/go-git/v5/plumbing"
40
41
comatproto "github.com/bluesky-social/indigo/api/atproto"
42
+
"github.com/bluesky-social/indigo/atproto/syntax"
43
lexutil "github.com/bluesky-social/indigo/lex/util"
44
)
45
···
52
spindlestream *eventconsumer.Consumer
53
db *db.DB
54
enforcer *rbac.Enforcer
55
+
notifier notify.Notifier
56
+
logger *slog.Logger
57
}
58
59
func New(
···
64
idResolver *idresolver.Resolver,
65
db *db.DB,
66
config *config.Config,
67
+
notifier notify.Notifier,
68
enforcer *rbac.Enforcer,
69
+
logger *slog.Logger,
70
) *Repo {
71
return &Repo{oauth: oauth,
72
repoResolver: repoResolver,
···
75
config: config,
76
spindlestream: spindlestream,
77
db: db,
78
+
notifier: notifier,
79
enforcer: enforcer,
80
+
logger: logger,
81
}
82
}
83
···
183
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
184
RepoInfo: f.RepoInfo(user),
185
})
186
}
187
188
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
···
377
378
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
379
// so we can safely redirect to the "parent" (which is the same file).
380
+
unescapedTreePath, _ := url.PathUnescape(treePath)
381
+
if len(result.Files) == 0 && result.Parent == unescapedTreePath {
382
http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
383
return
384
}
···
393
}
394
}
395
396
+
sortFiles(result.Files)
397
398
rp.pages.RepoTree(w, pages.RepoTreeParams{
399
LoggedInUser: user,
400
BreadCrumbs: breadcrumbs,
401
+
TreePath: treePath,
402
RepoInfo: f.RepoInfo(user),
403
RepoTreeResponse: result,
404
})
405
}
406
407
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
···
459
ArtifactMap: artifactMap,
460
DanglingArtifacts: danglingArtifacts,
461
})
462
}
463
464
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
···
480
return
481
}
482
483
+
sortBranches(result.Branches)
484
485
user := rp.oauth.GetUser(r)
486
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
···
488
RepoInfo: f.RepoInfo(user),
489
RepoBranchesResponse: *result,
490
})
491
}
492
493
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
···
538
showRendered = r.URL.Query().Get("code") != "true"
539
}
540
541
+
var unsupported bool
542
+
var isImage bool
543
+
var isVideo bool
544
+
var contentSrc string
545
+
546
+
if result.IsBinary {
547
+
ext := strings.ToLower(filepath.Ext(result.Path))
548
+
switch ext {
549
+
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
550
+
isImage = true
551
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
552
+
isVideo = true
553
+
default:
554
+
unsupported = true
555
+
}
556
+
557
+
// fetch the actual binary content like in RepoBlobRaw
558
+
559
+
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
560
+
contentSrc = blobURL
561
+
if !rp.config.Core.Dev {
562
+
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
563
+
}
564
+
}
565
+
566
user := rp.oauth.GetUser(r)
567
rp.pages.RepoBlob(w, pages.RepoBlobParams{
568
LoggedInUser: user,
···
571
BreadCrumbs: breadcrumbs,
572
ShowRendered: showRendered,
573
RenderToggle: renderToggle,
574
+
Unsupported: unsupported,
575
+
IsImage: isImage,
576
+
IsVideo: isVideo,
577
+
ContentSrc: contentSrc,
578
})
579
}
580
581
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
582
f, err := rp.repoResolver.Resolve(r)
583
if err != nil {
584
log.Println("failed to get repo and knot", err)
585
+
w.WriteHeader(http.StatusBadRequest)
586
return
587
}
588
···
593
if !rp.config.Core.Dev {
594
protocol = "https"
595
}
596
+
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
597
+
resp, err := http.Get(blobURL)
598
if err != nil {
599
+
log.Println("failed to reach knotserver:", err)
600
+
rp.pages.Error503(w)
601
return
602
}
603
+
defer resp.Body.Close()
604
605
+
if resp.StatusCode != http.StatusOK {
606
+
log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
607
+
w.WriteHeader(resp.StatusCode)
608
+
_, _ = io.Copy(w, resp.Body)
609
return
610
}
611
612
+
contentType := resp.Header.Get("Content-Type")
613
+
body, err := io.ReadAll(resp.Body)
614
if err != nil {
615
+
log.Printf("error reading response body from knotserver: %v", err)
616
+
w.WriteHeader(http.StatusInternalServerError)
617
return
618
}
619
620
+
if strings.Contains(contentType, "text/plain") {
621
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
622
+
w.Write(body)
623
+
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
624
+
w.Header().Set("Content-Type", contentType)
625
w.Write(body)
626
+
} else {
627
+
w.WriteHeader(http.StatusUnsupportedMediaType)
628
+
w.Write([]byte("unsupported content type"))
629
return
630
}
631
}
632
633
// modify the spindle configured for this repo
634
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
635
+
user := rp.oauth.GetUser(r)
636
+
l := rp.logger.With("handler", "EditSpindle")
637
+
l = l.With("did", user.Did)
638
+
l = l.With("handle", user.Handle)
639
+
640
+
errorId := "operation-error"
641
+
fail := func(msg string, err error) {
642
+
l.Error(msg, "err", err)
643
+
rp.pages.Notice(w, errorId, msg)
644
+
}
645
+
646
f, err := rp.repoResolver.Resolve(r)
647
if err != nil {
648
+
fail("Failed to resolve repo. Try again later", err)
649
return
650
}
651
652
repoAt := f.RepoAt
653
rkey := repoAt.RecordKey().String()
654
if rkey == "" {
655
+
fail("Failed to resolve repo. Try again later", err)
656
return
657
}
658
659
newSpindle := r.FormValue("spindle")
660
client, err := rp.oauth.AuthorizedClient(r)
661
if err != nil {
662
+
fail("Failed to authorize. Try again later.", err)
663
return
664
}
665
666
// ensure that this is a valid spindle for this user
667
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
668
if err != nil {
669
+
fail("Failed to find spindles. Try again later.", err)
670
return
671
}
672
673
if !slices.Contains(validSpindles, newSpindle) {
674
+
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
675
return
676
}
677
678
// optimistic update
679
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
680
if err != nil {
681
+
fail("Failed to update spindle. Try again later.", err)
682
return
683
}
684
685
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
686
if err != nil {
687
+
fail("Failed to update spindle, no record found on PDS.", err)
688
return
689
}
690
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
···
705
})
706
707
if err != nil {
708
+
fail("Failed to update spindle, unable to save to PDS.", err)
709
return
710
}
711
···
715
eventconsumer.NewSpindleSource(newSpindle),
716
)
717
718
+
rp.pages.HxRefresh(w)
719
}
720
721
func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
722
+
user := rp.oauth.GetUser(r)
723
+
l := rp.logger.With("handler", "AddCollaborator")
724
+
l = l.With("did", user.Did)
725
+
l = l.With("handle", user.Handle)
726
+
727
f, err := rp.repoResolver.Resolve(r)
728
if err != nil {
729
+
l.Error("failed to get repo and knot", "err", err)
730
return
731
+
}
732
+
733
+
errorId := "add-collaborator-error"
734
+
fail := func(msg string, err error) {
735
+
l.Error(msg, "err", err)
736
+
rp.pages.Notice(w, errorId, msg)
737
}
738
739
collaborator := r.FormValue("collaborator")
740
if collaborator == "" {
741
+
fail("Invalid form.", nil)
742
return
743
}
744
745
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
746
if err != nil {
747
+
fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
748
+
return
749
+
}
750
+
751
+
if collaboratorIdent.DID.String() == user.Did {
752
+
fail("You seem to be adding yourself as a collaborator.", nil)
753
return
754
}
755
+
l = l.With("collaborator", collaboratorIdent.Handle)
756
+
l = l.With("knot", f.Knot)
757
758
+
// announce this relation into the firehose, store into owners' pds
759
+
client, err := rp.oauth.AuthorizedClient(r)
760
+
if err != nil {
761
+
fail("Failed to write to PDS.", err)
762
+
return
763
+
}
764
765
+
// emit a record
766
+
currentUser := rp.oauth.GetUser(r)
767
+
rkey := tid.TID()
768
+
createdAt := time.Now()
769
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
770
+
Collection: tangled.RepoCollaboratorNSID,
771
+
Repo: currentUser.Did,
772
+
Rkey: rkey,
773
+
Record: &lexutil.LexiconTypeDecoder{
774
+
Val: &tangled.RepoCollaborator{
775
+
Subject: collaboratorIdent.DID.String(),
776
+
Repo: string(f.RepoAt),
777
+
CreatedAt: createdAt.Format(time.RFC3339),
778
+
}},
779
+
})
780
+
// invalid record
781
+
if err != nil {
782
+
fail("Failed to write record to PDS.", err)
783
+
return
784
+
}
785
+
l = l.With("at-uri", resp.Uri)
786
+
l.Info("wrote record to PDS")
787
+
788
+
l.Info("adding to knot")
789
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
790
if err != nil {
791
+
fail("Failed to add to knot.", err)
792
return
793
}
794
795
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
796
if err != nil {
797
+
fail("Failed to add to knot.", err)
798
return
799
}
800
801
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
802
if err != nil {
803
+
fail("Knot was unreachable.", err)
804
return
805
}
806
807
if ksResp.StatusCode != http.StatusNoContent {
808
+
fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil)
809
return
810
}
811
812
tx, err := rp.db.BeginTx(r.Context(), nil)
813
if err != nil {
814
+
fail("Failed to add collaborator.", err)
815
return
816
}
817
defer func() {
818
tx.Rollback()
819
err = rp.enforcer.E.LoadPolicy()
820
if err != nil {
821
+
fail("Failed to add collaborator.", err)
822
}
823
}()
824
825
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
826
if err != nil {
827
+
fail("Failed to add collaborator permissions.", err)
828
return
829
}
830
831
+
err = db.AddCollaborator(rp.db, db.Collaborator{
832
+
Did: syntax.DID(currentUser.Did),
833
+
Rkey: rkey,
834
+
SubjectDid: collaboratorIdent.DID,
835
+
RepoAt: f.RepoAt,
836
+
Created: createdAt,
837
+
})
838
if err != nil {
839
+
fail("Failed to add collaborator.", err)
840
return
841
}
842
843
err = tx.Commit()
844
if err != nil {
845
+
fail("Failed to add collaborator.", err)
846
return
847
}
848
849
err = rp.enforcer.E.SavePolicy()
850
if err != nil {
851
+
fail("Failed to update collaborator permissions.", err)
852
return
853
}
854
855
+
rp.pages.HxRefresh(w)
856
}
857
858
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
···
1004
w.Write(fmt.Append(nil, "default branch set to: ", branch))
1005
}
1006
1007
+
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1008
+
user := rp.oauth.GetUser(r)
1009
+
l := rp.logger.With("handler", "Secrets")
1010
+
l = l.With("handle", user.Handle)
1011
+
l = l.With("did", user.Did)
1012
+
1013
f, err := rp.repoResolver.Resolve(r)
1014
if err != nil {
1015
log.Println("failed to get repo and knot", err)
1016
return
1017
}
1018
1019
+
if f.Spindle == "" {
1020
+
log.Println("empty spindle cannot add/rm secret", err)
1021
+
return
1022
+
}
1023
+
1024
+
lxm := tangled.RepoAddSecretNSID
1025
+
if r.Method == http.MethodDelete {
1026
+
lxm = tangled.RepoRemoveSecretNSID
1027
+
}
1028
+
1029
+
spindleClient, err := rp.oauth.ServiceClient(
1030
+
r,
1031
+
oauth.WithService(f.Spindle),
1032
+
oauth.WithLxm(lxm),
1033
+
oauth.WithDev(rp.config.Core.Dev),
1034
+
)
1035
+
if err != nil {
1036
+
log.Println("failed to create spindle client", err)
1037
+
return
1038
+
}
1039
+
1040
+
key := r.FormValue("key")
1041
+
if key == "" {
1042
+
w.WriteHeader(http.StatusBadRequest)
1043
+
return
1044
+
}
1045
+
1046
switch r.Method {
1047
+
case http.MethodPut:
1048
+
errorId := "add-secret-error"
1049
1050
+
value := r.FormValue("value")
1051
+
if value == "" {
1052
+
w.WriteHeader(http.StatusBadRequest)
1053
+
return
1054
}
1055
1056
+
err = tangled.RepoAddSecret(
1057
+
r.Context(),
1058
+
spindleClient,
1059
+
&tangled.RepoAddSecret_Input{
1060
+
Repo: f.RepoAt.String(),
1061
+
Key: key,
1062
+
Value: value,
1063
+
},
1064
+
)
1065
if err != nil {
1066
+
l.Error("Failed to add secret.", "err", err)
1067
+
rp.pages.Notice(w, errorId, "Failed to add secret.")
1068
return
1069
}
1070
1071
+
case http.MethodDelete:
1072
+
errorId := "operation-error"
1073
+
1074
+
err = tangled.RepoRemoveSecret(
1075
+
r.Context(),
1076
+
spindleClient,
1077
+
&tangled.RepoRemoveSecret_Input{
1078
+
Repo: f.RepoAt.String(),
1079
+
Key: key,
1080
+
},
1081
+
)
1082
if err != nil {
1083
+
l.Error("Failed to delete secret.", "err", err)
1084
+
rp.pages.Notice(w, errorId, "Failed to delete secret.")
1085
return
1086
}
1087
+
}
1088
1089
+
rp.pages.HxRefresh(w)
1090
+
}
1091
+
1092
+
type tab = map[string]any
1093
+
1094
+
var (
1095
+
// would be great to have ordered maps right about now
1096
+
settingsTabs []tab = []tab{
1097
+
{"Name": "general", "Icon": "sliders-horizontal"},
1098
+
{"Name": "access", "Icon": "users"},
1099
+
{"Name": "pipelines", "Icon": "layers-2"},
1100
+
}
1101
+
)
1102
+
1103
+
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1104
+
tabVal := r.URL.Query().Get("tab")
1105
+
if tabVal == "" {
1106
+
tabVal = "general"
1107
+
}
1108
+
1109
+
switch tabVal {
1110
+
case "general":
1111
+
rp.generalSettings(w, r)
1112
+
1113
+
case "access":
1114
+
rp.accessSettings(w, r)
1115
+
1116
+
case "pipelines":
1117
+
rp.pipelineSettings(w, r)
1118
+
}
1119
+
1120
+
// user := rp.oauth.GetUser(r)
1121
+
// repoCollaborators, err := f.Collaborators(r.Context())
1122
+
// if err != nil {
1123
+
// log.Println("failed to get collaborators", err)
1124
+
// }
1125
+
1126
+
// isCollaboratorInviteAllowed := false
1127
+
// if user != nil {
1128
+
// ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
1129
+
// if err == nil && ok {
1130
+
// isCollaboratorInviteAllowed = true
1131
+
// }
1132
+
// }
1133
+
1134
+
// us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1135
+
// if err != nil {
1136
+
// log.Println("failed to create unsigned client", err)
1137
+
// return
1138
+
// }
1139
+
1140
+
// result, err := us.Branches(f.OwnerDid(), f.RepoName)
1141
+
// if err != nil {
1142
+
// log.Println("failed to reach knotserver", err)
1143
+
// return
1144
+
// }
1145
+
1146
+
// // all spindles that this user is a member of
1147
+
// spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1148
+
// if err != nil {
1149
+
// log.Println("failed to fetch spindles", err)
1150
+
// return
1151
+
// }
1152
+
1153
+
// var secrets []*tangled.RepoListSecrets_Secret
1154
+
// if f.Spindle != "" {
1155
+
// if spindleClient, err := rp.oauth.ServiceClient(
1156
+
// r,
1157
+
// oauth.WithService(f.Spindle),
1158
+
// oauth.WithLxm(tangled.RepoListSecretsNSID),
1159
+
// oauth.WithDev(rp.config.Core.Dev),
1160
+
// ); err != nil {
1161
+
// log.Println("failed to create spindle client", err)
1162
+
// } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil {
1163
+
// log.Println("failed to fetch secrets", err)
1164
+
// } else {
1165
+
// secrets = resp.Secrets
1166
+
// }
1167
+
// }
1168
+
1169
+
// rp.pages.RepoSettings(w, pages.RepoSettingsParams{
1170
+
// LoggedInUser: user,
1171
+
// RepoInfo: f.RepoInfo(user),
1172
+
// Collaborators: repoCollaborators,
1173
+
// IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1174
+
// Branches: result.Branches,
1175
+
// Spindles: spindles,
1176
+
// CurrentSpindle: f.Spindle,
1177
+
// Secrets: secrets,
1178
+
// })
1179
+
}
1180
+
1181
+
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1182
+
f, err := rp.repoResolver.Resolve(r)
1183
+
user := rp.oauth.GetUser(r)
1184
+
1185
+
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1186
+
if err != nil {
1187
+
log.Println("failed to create unsigned client", err)
1188
+
return
1189
+
}
1190
+
1191
+
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1192
+
if err != nil {
1193
+
log.Println("failed to reach knotserver", err)
1194
+
return
1195
+
}
1196
+
1197
+
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1198
+
LoggedInUser: user,
1199
+
RepoInfo: f.RepoInfo(user),
1200
+
Branches: result.Branches,
1201
+
Tabs: settingsTabs,
1202
+
Tab: "general",
1203
+
})
1204
+
}
1205
+
1206
+
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
1207
+
f, err := rp.repoResolver.Resolve(r)
1208
+
user := rp.oauth.GetUser(r)
1209
+
1210
+
repoCollaborators, err := f.Collaborators(r.Context())
1211
+
if err != nil {
1212
+
log.Println("failed to get collaborators", err)
1213
+
}
1214
+
1215
+
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
1216
+
LoggedInUser: user,
1217
+
RepoInfo: f.RepoInfo(user),
1218
+
Tabs: settingsTabs,
1219
+
Tab: "access",
1220
+
Collaborators: repoCollaborators,
1221
+
})
1222
+
}
1223
+
1224
+
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
1225
+
f, err := rp.repoResolver.Resolve(r)
1226
+
user := rp.oauth.GetUser(r)
1227
+
1228
+
// all spindles that the repo owner is a member of
1229
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1230
+
if err != nil {
1231
+
log.Println("failed to fetch spindles", err)
1232
+
return
1233
+
}
1234
+
1235
+
var secrets []*tangled.RepoListSecrets_Secret
1236
+
if f.Spindle != "" {
1237
+
if spindleClient, err := rp.oauth.ServiceClient(
1238
+
r,
1239
+
oauth.WithService(f.Spindle),
1240
+
oauth.WithLxm(tangled.RepoListSecretsNSID),
1241
+
oauth.WithDev(rp.config.Core.Dev),
1242
+
); err != nil {
1243
+
log.Println("failed to create spindle client", err)
1244
+
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil {
1245
+
log.Println("failed to fetch secrets", err)
1246
+
} else {
1247
+
secrets = resp.Secrets
1248
}
1249
+
}
1250
1251
+
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
1252
+
return strings.Compare(a.Key, b.Key)
1253
+
})
1254
+
1255
+
var dids []string
1256
+
for _, s := range secrets {
1257
+
dids = append(dids, s.CreatedBy)
1258
+
}
1259
+
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
1260
+
1261
+
// convert to a more manageable form
1262
+
var niceSecret []map[string]any
1263
+
for id, s := range secrets {
1264
+
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
1265
+
niceSecret = append(niceSecret, map[string]any{
1266
+
"Id": id,
1267
+
"Key": s.Key,
1268
+
"CreatedAt": when,
1269
+
"CreatedBy": resolvedIdents[id].Handle.String(),
1270
})
1271
}
1272
+
1273
+
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
1274
+
LoggedInUser: user,
1275
+
RepoInfo: f.RepoInfo(user),
1276
+
Tabs: settingsTabs,
1277
+
Tab: "pipelines",
1278
+
Spindles: spindles,
1279
+
CurrentSpindle: f.Spindle,
1280
+
Secrets: niceSecret,
1281
+
})
1282
}
1283
1284
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
···
1398
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1399
sourceAt := f.RepoAt.String()
1400
1401
+
rkey := tid.TID()
1402
repo := &db.Repo{
1403
Did: user.Did,
1404
Name: forkName,
···
1523
return
1524
}
1525
branches := result.Branches
1526
+
1527
+
sortBranches(branches)
1528
1529
var defaultBranch string
1530
for _, b := range branches {
+34
appview/repo/repo_util.go
+34
appview/repo/repo_util.go
···
5
"crypto/rand"
6
"fmt"
7
"math/big"
8
+
"slices"
9
+
"sort"
10
+
"strings"
11
12
"tangled.sh/tangled.sh/core/appview/db"
13
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
14
+
"tangled.sh/tangled.sh/core/types"
15
16
"github.com/go-git/go-git/v5/plumbing/object"
17
)
18
+
19
+
func sortFiles(files []types.NiceTree) {
20
+
sort.Slice(files, func(i, j int) bool {
21
+
iIsFile := files[i].IsFile
22
+
jIsFile := files[j].IsFile
23
+
if iIsFile != jIsFile {
24
+
return !iIsFile
25
+
}
26
+
return files[i].Name < files[j].Name
27
+
})
28
+
}
29
+
30
+
func sortBranches(branches []types.Branch) {
31
+
slices.SortFunc(branches, func(a, b types.Branch) int {
32
+
if a.IsDefault {
33
+
return -1
34
+
}
35
+
if b.IsDefault {
36
+
return 1
37
+
}
38
+
if a.Commit != nil && b.Commit != nil {
39
+
if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
40
+
return 1
41
+
} else {
42
+
return -1
43
+
}
44
+
}
45
+
return strings.Compare(a.Name, b.Name)
46
+
})
47
+
}
48
49
func uniqueEmails(commits []*object.Commit) []string {
50
emails := make(map[string]struct{})
+2
appview/repo/router.go
+2
appview/repo/router.go
···
74
r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator)
75
r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo)
76
r.Put("/branches/default", rp.SetDefaultBranch)
77
+
r.Put("/secrets", rp.Secrets)
78
+
r.Delete("/secrets", rp.Secrets)
79
})
80
})
81
+5
-4
appview/reporesolver/resolver.go
+5
-4
appview/reporesolver/resolver.go
···
17
"github.com/go-chi/chi/v5"
18
"tangled.sh/tangled.sh/core/appview/config"
19
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
21
"tangled.sh/tangled.sh/core/appview/oauth"
22
"tangled.sh/tangled.sh/core/appview/pages"
23
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
24
"tangled.sh/tangled.sh/core/knotclient"
25
"tangled.sh/tangled.sh/core/rbac"
26
)
···
149
for _, item := range repoCollaborators {
150
// currently only two roles: owner and member
151
var role string
152
-
if item[3] == "repo:owner" {
153
role = "owner"
154
-
} else if item[3] == "repo:collaborator" {
155
role = "collaborator"
156
-
} else {
157
continue
158
}
159
···
17
"github.com/go-chi/chi/v5"
18
"tangled.sh/tangled.sh/core/appview/config"
19
"tangled.sh/tangled.sh/core/appview/db"
20
"tangled.sh/tangled.sh/core/appview/oauth"
21
"tangled.sh/tangled.sh/core/appview/pages"
22
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
23
+
"tangled.sh/tangled.sh/core/idresolver"
24
"tangled.sh/tangled.sh/core/knotclient"
25
"tangled.sh/tangled.sh/core/rbac"
26
)
···
149
for _, item := range repoCollaborators {
150
// currently only two roles: owner and member
151
var role string
152
+
switch item[3] {
153
+
case "repo:owner":
154
role = "owner"
155
+
case "repo:collaborator":
156
role = "collaborator"
157
+
default:
158
continue
159
}
160
+2
-2
appview/settings/settings.go
+2
-2
appview/settings/settings.go
···
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/config"
17
"tangled.sh/tangled.sh/core/appview/db"
18
"tangled.sh/tangled.sh/core/appview/email"
19
"tangled.sh/tangled.sh/core/appview/middleware"
20
"tangled.sh/tangled.sh/core/appview/oauth"
21
"tangled.sh/tangled.sh/core/appview/pages"
22
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
lexutil "github.com/bluesky-social/indigo/lex/util"
···
366
return
367
}
368
369
-
rkey := appview.TID()
370
371
tx, err := s.Db.Begin()
372
if err != nil {
···
12
13
"github.com/go-chi/chi/v5"
14
"tangled.sh/tangled.sh/core/api/tangled"
15
"tangled.sh/tangled.sh/core/appview/config"
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
+
"tangled.sh/tangled.sh/core/tid"
22
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
lexutil "github.com/bluesky-social/indigo/lex/util"
···
366
return
367
}
368
369
+
rkey := tid.TID()
370
371
tx, err := s.Db.Begin()
372
if err != nil {
+104
appview/signup/requests.go
+104
appview/signup/requests.go
···
···
1
+
package signup
2
+
3
+
// We have this extra code here for now since the xrpcclient package
4
+
// only supports OAuth'd requests; these are unauthenticated or use PDS admin auth.
5
+
6
+
import (
7
+
"bytes"
8
+
"encoding/json"
9
+
"fmt"
10
+
"io"
11
+
"net/http"
12
+
"net/url"
13
+
)
14
+
15
+
// makePdsRequest is a helper method to make requests to the PDS service
16
+
func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) {
17
+
jsonData, err := json.Marshal(body)
18
+
if err != nil {
19
+
return nil, err
20
+
}
21
+
22
+
url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint)
23
+
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
24
+
if err != nil {
25
+
return nil, err
26
+
}
27
+
28
+
req.Header.Set("Content-Type", "application/json")
29
+
30
+
if useAuth {
31
+
req.SetBasicAuth("admin", s.config.Pds.AdminSecret)
32
+
}
33
+
34
+
return http.DefaultClient.Do(req)
35
+
}
36
+
37
+
// handlePdsError processes error responses from the PDS service
38
+
func (s *Signup) handlePdsError(resp *http.Response, action string) error {
39
+
var errorResp struct {
40
+
Error string `json:"error"`
41
+
Message string `json:"message"`
42
+
}
43
+
44
+
respBody, _ := io.ReadAll(resp.Body)
45
+
if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" {
46
+
return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message)
47
+
}
48
+
49
+
// Fallback if we couldn't parse the error
50
+
return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode)
51
+
}
52
+
53
+
func (s *Signup) inviteCodeRequest() (string, error) {
54
+
body := map[string]any{"useCount": 1}
55
+
56
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true)
57
+
if err != nil {
58
+
return "", err
59
+
}
60
+
defer resp.Body.Close()
61
+
62
+
if resp.StatusCode != http.StatusOK {
63
+
return "", s.handlePdsError(resp, "create invite code")
64
+
}
65
+
66
+
var result map[string]string
67
+
json.NewDecoder(resp.Body).Decode(&result)
68
+
return result["code"], nil
69
+
}
70
+
71
+
func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) {
72
+
parsedURL, err := url.Parse(s.config.Pds.Host)
73
+
if err != nil {
74
+
return "", fmt.Errorf("invalid PDS host URL: %w", err)
75
+
}
76
+
77
+
pdsDomain := parsedURL.Hostname()
78
+
79
+
body := map[string]string{
80
+
"email": email,
81
+
"handle": fmt.Sprintf("%s.%s", username, pdsDomain),
82
+
"password": password,
83
+
"inviteCode": code,
84
+
}
85
+
86
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false)
87
+
if err != nil {
88
+
return "", err
89
+
}
90
+
defer resp.Body.Close()
91
+
92
+
if resp.StatusCode != http.StatusOK {
93
+
return "", s.handlePdsError(resp, "create account")
94
+
}
95
+
96
+
var result struct {
97
+
DID string `json:"did"`
98
+
}
99
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
100
+
return "", fmt.Errorf("failed to decode create account response: %w", err)
101
+
}
102
+
103
+
return result.DID, nil
104
+
}
+256
appview/signup/signup.go
+256
appview/signup/signup.go
···
···
1
+
package signup
2
+
3
+
import (
4
+
"bufio"
5
+
"fmt"
6
+
"log/slog"
7
+
"net/http"
8
+
"os"
9
+
"strings"
10
+
11
+
"github.com/go-chi/chi/v5"
12
+
"github.com/posthog/posthog-go"
13
+
"tangled.sh/tangled.sh/core/appview/config"
14
+
"tangled.sh/tangled.sh/core/appview/db"
15
+
"tangled.sh/tangled.sh/core/appview/dns"
16
+
"tangled.sh/tangled.sh/core/appview/email"
17
+
"tangled.sh/tangled.sh/core/appview/pages"
18
+
"tangled.sh/tangled.sh/core/appview/state/userutil"
19
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
+
)
22
+
23
+
type Signup struct {
24
+
config *config.Config
25
+
db *db.DB
26
+
cf *dns.Cloudflare
27
+
posthog posthog.Client
28
+
xrpc *xrpcclient.Client
29
+
idResolver *idresolver.Resolver
30
+
pages *pages.Pages
31
+
l *slog.Logger
32
+
disallowedNicknames map[string]bool
33
+
}
34
+
35
+
func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup {
36
+
var cf *dns.Cloudflare
37
+
if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" {
38
+
var err error
39
+
cf, err = dns.NewCloudflare(cfg)
40
+
if err != nil {
41
+
l.Warn("failed to create cloudflare client, signup will be disabled", "error", err)
42
+
}
43
+
}
44
+
45
+
disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l)
46
+
47
+
return &Signup{
48
+
config: cfg,
49
+
db: database,
50
+
posthog: pc,
51
+
idResolver: idResolver,
52
+
cf: cf,
53
+
pages: pages,
54
+
l: l,
55
+
disallowedNicknames: disallowedNicknames,
56
+
}
57
+
}
58
+
59
+
func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool {
60
+
disallowed := make(map[string]bool)
61
+
62
+
if filepath == "" {
63
+
logger.Debug("no disallowed nicknames file configured")
64
+
return disallowed
65
+
}
66
+
67
+
file, err := os.Open(filepath)
68
+
if err != nil {
69
+
logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err)
70
+
return disallowed
71
+
}
72
+
defer file.Close()
73
+
74
+
scanner := bufio.NewScanner(file)
75
+
lineNum := 0
76
+
for scanner.Scan() {
77
+
lineNum++
78
+
line := strings.TrimSpace(scanner.Text())
79
+
if line == "" || strings.HasPrefix(line, "#") {
80
+
continue // skip empty lines and comments
81
+
}
82
+
83
+
nickname := strings.ToLower(line)
84
+
if userutil.IsValidSubdomain(nickname) {
85
+
disallowed[nickname] = true
86
+
} else {
87
+
logger.Warn("invalid nickname format in disallowed nicknames file",
88
+
"file", filepath, "line", lineNum, "nickname", nickname)
89
+
}
90
+
}
91
+
92
+
if err := scanner.Err(); err != nil {
93
+
logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err)
94
+
}
95
+
96
+
logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath)
97
+
return disallowed
98
+
}
99
+
100
+
// isNicknameAllowed checks if a nickname is allowed (not in the disallowed list)
101
+
func (s *Signup) isNicknameAllowed(nickname string) bool {
102
+
return !s.disallowedNicknames[strings.ToLower(nickname)]
103
+
}
104
+
105
+
func (s *Signup) Router() http.Handler {
106
+
r := chi.NewRouter()
107
+
r.Get("/", s.signup)
108
+
r.Post("/", s.signup)
109
+
r.Get("/complete", s.complete)
110
+
r.Post("/complete", s.complete)
111
+
112
+
return r
113
+
}
114
+
115
+
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
116
+
switch r.Method {
117
+
case http.MethodGet:
118
+
s.pages.Signup(w)
119
+
case http.MethodPost:
120
+
if s.cf == nil {
121
+
http.Error(w, "signup is disabled", http.StatusFailedDependency)
122
+
}
123
+
emailId := r.FormValue("email")
124
+
125
+
noticeId := "signup-msg"
126
+
if !email.IsValidEmail(emailId) {
127
+
s.pages.Notice(w, noticeId, "Invalid email address.")
128
+
return
129
+
}
130
+
131
+
exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
132
+
if err != nil {
133
+
s.l.Error("failed to check email existence", "error", err)
134
+
s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.")
135
+
return
136
+
}
137
+
if exists {
138
+
s.pages.Notice(w, noticeId, "Email already exists.")
139
+
return
140
+
}
141
+
142
+
code, err := s.inviteCodeRequest()
143
+
if err != nil {
144
+
s.l.Error("failed to create invite code", "error", err)
145
+
s.pages.Notice(w, noticeId, "Failed to create invite code.")
146
+
return
147
+
}
148
+
149
+
em := email.Email{
150
+
APIKey: s.config.Resend.ApiKey,
151
+
From: s.config.Resend.SentFrom,
152
+
To: emailId,
153
+
Subject: "Verify your Tangled account",
154
+
Text: `Copy and paste this code below to verify your account on Tangled.
155
+
` + code,
156
+
Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
157
+
<p><code>` + code + `</code></p>`,
158
+
}
159
+
160
+
err = email.SendEmail(em)
161
+
if err != nil {
162
+
s.l.Error("failed to send email", "error", err)
163
+
s.pages.Notice(w, noticeId, "Failed to send email.")
164
+
return
165
+
}
166
+
err = db.AddInflightSignup(s.db, db.InflightSignup{
167
+
Email: emailId,
168
+
InviteCode: code,
169
+
})
170
+
if err != nil {
171
+
s.l.Error("failed to add inflight signup", "error", err)
172
+
s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.")
173
+
return
174
+
}
175
+
176
+
s.pages.HxRedirect(w, "/signup/complete")
177
+
}
178
+
}
179
+
180
+
func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
181
+
switch r.Method {
182
+
case http.MethodGet:
183
+
s.pages.CompleteSignup(w)
184
+
case http.MethodPost:
185
+
username := r.FormValue("username")
186
+
password := r.FormValue("password")
187
+
code := r.FormValue("code")
188
+
189
+
if !userutil.IsValidSubdomain(username) {
190
+
s.pages.Notice(w, "signup-error", "Invalid username. Username must be 4โ63 characters, lowercase letters, digits, or hyphens, and can't start or end with a hyphen.")
191
+
return
192
+
}
193
+
194
+
if !s.isNicknameAllowed(username) {
195
+
s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.")
196
+
return
197
+
}
198
+
199
+
email, err := db.GetEmailForCode(s.db, code)
200
+
if err != nil {
201
+
s.l.Error("failed to get email for code", "error", err)
202
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
203
+
return
204
+
}
205
+
206
+
did, err := s.createAccountRequest(username, password, email, code)
207
+
if err != nil {
208
+
s.l.Error("failed to create account", "error", err)
209
+
s.pages.Notice(w, "signup-error", err.Error())
210
+
return
211
+
}
212
+
213
+
if s.cf == nil {
214
+
s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
215
+
s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
216
+
return
217
+
}
218
+
219
+
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
220
+
Type: "TXT",
221
+
Name: "_atproto." + username,
222
+
Content: "did=" + did,
223
+
TTL: 6400,
224
+
Proxied: false,
225
+
})
226
+
if err != nil {
227
+
s.l.Error("failed to create DNS record", "error", err)
228
+
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
229
+
return
230
+
}
231
+
232
+
err = db.AddEmail(s.db, db.Email{
233
+
Did: did,
234
+
Address: email,
235
+
Verified: true,
236
+
Primary: true,
237
+
})
238
+
if err != nil {
239
+
s.l.Error("failed to add email", "error", err)
240
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
241
+
return
242
+
}
243
+
244
+
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
245
+
<a class="underline text-black dark:text-white" href="/login">login</a>
246
+
with <code>%s.tngl.sh</code>.`, username))
247
+
248
+
go func() {
249
+
err := db.DeleteInflightSignup(s.db, email)
250
+
if err != nil {
251
+
s.l.Error("failed to delete inflight signup", "error", err)
252
+
}
253
+
}()
254
+
return
255
+
}
256
+
}
+16
-8
appview/spindles/spindles.go
+16
-8
appview/spindles/spindles.go
···
10
11
"github.com/go-chi/chi/v5"
12
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview"
14
"tangled.sh/tangled.sh/core/appview/config"
15
"tangled.sh/tangled.sh/core/appview/db"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
17
"tangled.sh/tangled.sh/core/appview/middleware"
18
"tangled.sh/tangled.sh/core/appview/oauth"
19
"tangled.sh/tangled.sh/core/appview/pages"
20
verify "tangled.sh/tangled.sh/core/appview/spindleverify"
21
"tangled.sh/tangled.sh/core/rbac"
22
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
"github.com/bluesky-social/indigo/atproto/syntax"
···
114
}
115
116
identsToResolve := make([]string, len(members))
117
-
for i, member := range members {
118
-
identsToResolve[i] = member
119
-
}
120
resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve)
121
didHandleMap := make(map[string]string)
122
for _, identity := range resolvedIds {
···
258
259
// ok
260
s.Pages.HxRefresh(w)
261
-
return
262
}
263
264
func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) {
···
306
s.Enforcer.E.LoadPolicy()
307
}()
308
309
err = db.DeleteSpindle(
310
tx,
311
db.FilterEq("owner", user.Did),
···
524
s.Enforcer.E.LoadPolicy()
525
}()
526
527
-
rkey := appview.TID()
528
529
// add member to db
530
if err = db.AddSpindleMember(tx, db.SpindleMember{
···
711
712
// ok
713
s.Pages.HxRefresh(w)
714
-
return
715
}
···
10
11
"github.com/go-chi/chi/v5"
12
"tangled.sh/tangled.sh/core/api/tangled"
13
"tangled.sh/tangled.sh/core/appview/config"
14
"tangled.sh/tangled.sh/core/appview/db"
15
"tangled.sh/tangled.sh/core/appview/middleware"
16
"tangled.sh/tangled.sh/core/appview/oauth"
17
"tangled.sh/tangled.sh/core/appview/pages"
18
verify "tangled.sh/tangled.sh/core/appview/spindleverify"
19
+
"tangled.sh/tangled.sh/core/idresolver"
20
"tangled.sh/tangled.sh/core/rbac"
21
+
"tangled.sh/tangled.sh/core/tid"
22
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
"github.com/bluesky-social/indigo/atproto/syntax"
···
114
}
115
116
identsToResolve := make([]string, len(members))
117
+
copy(identsToResolve, members)
118
resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve)
119
didHandleMap := make(map[string]string)
120
for _, identity := range resolvedIds {
···
256
257
// ok
258
s.Pages.HxRefresh(w)
259
}
260
261
func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) {
···
303
s.Enforcer.E.LoadPolicy()
304
}()
305
306
+
// remove spindle members first
307
+
err = db.RemoveSpindleMember(
308
+
tx,
309
+
db.FilterEq("did", user.Did),
310
+
db.FilterEq("instance", instance),
311
+
)
312
+
if err != nil {
313
+
l.Error("failed to remove spindle members", "err", err)
314
+
fail()
315
+
return
316
+
}
317
+
318
err = db.DeleteSpindle(
319
tx,
320
db.FilterEq("owner", user.Did),
···
533
s.Enforcer.E.LoadPolicy()
534
}()
535
536
+
rkey := tid.TID()
537
538
// add member to db
539
if err = db.AddSpindleMember(tx, db.SpindleMember{
···
720
721
// ok
722
s.Pages.HxRefresh(w)
723
}
+13
-26
appview/state/follow.go
+13
-26
appview/state/follow.go
···
7
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
-
"github.com/posthog/posthog-go"
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) Follow(w http.ResponseWriter, r *http.Request) {
···
42
switch r.Method {
43
case http.MethodPost:
44
createdAt := time.Now().Format(time.RFC3339)
45
-
rkey := appview.TID()
46
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
47
Collection: tangled.GraphFollowNSID,
48
Repo: currentUser.Did,
···
58
return
59
}
60
61
-
err = db.AddFollow(s.db, currentUser.Did, subjectIdent.DID.String(), rkey)
62
if err != nil {
63
log.Println("failed to follow", err)
64
return
65
}
66
67
-
log.Println("created atproto record: ", resp.Uri)
68
69
s.pages.FollowFragment(w, pages.FollowFragmentParams{
70
UserDid: subjectIdent.DID.String(),
71
FollowStatus: db.IsFollowing,
72
})
73
74
-
if !s.config.Core.Dev {
75
-
err = s.posthog.Enqueue(posthog.Capture{
76
-
DistinctId: currentUser.Did,
77
-
Event: "follow",
78
-
Properties: posthog.Properties{"subject": subjectIdent.DID.String()},
79
-
})
80
-
if err != nil {
81
-
log.Println("failed to enqueue posthog event:", err)
82
-
}
83
-
}
84
-
85
return
86
case http.MethodDelete:
87
// find the record in the db
···
113
FollowStatus: db.IsNotFollowing,
114
})
115
116
-
if !s.config.Core.Dev {
117
-
err = s.posthog.Enqueue(posthog.Capture{
118
-
DistinctId: currentUser.Did,
119
-
Event: "unfollow",
120
-
Properties: posthog.Properties{"subject": subjectIdent.DID.String()},
121
-
})
122
-
if err != nil {
123
-
log.Println("failed to enqueue posthog event:", err)
124
-
}
125
-
}
126
127
return
128
}
···
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/db"
12
"tangled.sh/tangled.sh/core/appview/pages"
13
+
"tangled.sh/tangled.sh/core/tid"
14
)
15
16
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
···
41
switch r.Method {
42
case http.MethodPost:
43
createdAt := time.Now().Format(time.RFC3339)
44
+
rkey := tid.TID()
45
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
46
Collection: tangled.GraphFollowNSID,
47
Repo: currentUser.Did,
···
57
return
58
}
59
60
+
log.Println("created atproto record: ", resp.Uri)
61
+
62
+
follow := &db.Follow{
63
+
UserDid: currentUser.Did,
64
+
SubjectDid: subjectIdent.DID.String(),
65
+
Rkey: rkey,
66
+
}
67
+
68
+
err = db.AddFollow(s.db, follow)
69
if err != nil {
70
log.Println("failed to follow", err)
71
return
72
}
73
74
+
s.notifier.NewFollow(r.Context(), follow)
75
76
s.pages.FollowFragment(w, pages.FollowFragmentParams{
77
UserDid: subjectIdent.DID.String(),
78
FollowStatus: db.IsFollowing,
79
})
80
81
return
82
case http.MethodDelete:
83
// find the record in the db
···
109
FollowStatus: db.IsNotFollowing,
110
})
111
112
+
s.notifier.DeleteFollow(r.Context(), follow)
113
114
return
115
}
+1
-29
appview/state/profile.go
+1
-29
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"
···
16
"github.com/bluesky-social/indigo/atproto/syntax"
17
lexutil "github.com/bluesky-social/indigo/lex/util"
18
"github.com/go-chi/chi/v5"
19
-
"github.com/posthog/posthog-go"
20
"tangled.sh/tangled.sh/core/api/tangled"
21
"tangled.sh/tangled.sh/core/appview/db"
22
"tangled.sh/tangled.sh/core/appview/pages"
···
143
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
144
}
145
146
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
147
s.pages.ProfilePage(w, pages.ProfilePageParams{
148
LoggedInUser: loggedInUser,
149
Repos: pinnedRepos,
···
152
Card: pages.ProfileCard{
153
UserDid: ident.DID.String(),
154
UserHandle: ident.Handle.String(),
155
-
AvatarUri: profileAvatarUri,
156
Profile: profile,
157
FollowStatus: followStatus,
158
Followers: followers,
···
195
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
196
}
197
198
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
199
-
200
s.pages.ReposPage(w, pages.ReposPageParams{
201
LoggedInUser: loggedInUser,
202
Repos: repos,
···
204
Card: pages.ProfileCard{
205
UserDid: ident.DID.String(),
206
UserHandle: ident.Handle.String(),
207
-
AvatarUri: profileAvatarUri,
208
Profile: profile,
209
FollowStatus: followStatus,
210
Followers: followers,
···
213
})
214
}
215
216
-
func (s *State) GetAvatarUri(handle string) string {
217
-
secret := s.config.Avatar.SharedSecret
218
-
h := hmac.New(sha256.New, []byte(secret))
219
-
h.Write([]byte(handle))
220
-
signature := hex.EncodeToString(h.Sum(nil))
221
-
return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle)
222
-
}
223
-
224
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
225
user := s.oauth.GetUser(r)
226
···
266
}
267
268
s.updateProfile(profile, w, r)
269
-
return
270
}
271
272
func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
···
306
profile.PinnedRepos = pinnedRepos
307
308
s.updateProfile(profile, w, r)
309
-
return
310
}
311
312
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
···
371
return
372
}
373
374
-
if !s.config.Core.Dev {
375
-
err = s.posthog.Enqueue(posthog.Capture{
376
-
DistinctId: user.Did,
377
-
Event: "edit_profile",
378
-
})
379
-
if err != nil {
380
-
log.Println("failed to enqueue posthog event:", err)
381
-
}
382
-
}
383
384
s.pages.HxRedirect(w, "/"+user.Did)
385
-
return
386
}
387
388
func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
···
1
package state
2
3
import (
4
"fmt"
5
"log"
6
"net/http"
···
13
"github.com/bluesky-social/indigo/atproto/syntax"
14
lexutil "github.com/bluesky-social/indigo/lex/util"
15
"github.com/go-chi/chi/v5"
16
"tangled.sh/tangled.sh/core/api/tangled"
17
"tangled.sh/tangled.sh/core/appview/db"
18
"tangled.sh/tangled.sh/core/appview/pages"
···
139
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
140
}
141
142
s.pages.ProfilePage(w, pages.ProfilePageParams{
143
LoggedInUser: loggedInUser,
144
Repos: pinnedRepos,
···
147
Card: pages.ProfileCard{
148
UserDid: ident.DID.String(),
149
UserHandle: ident.Handle.String(),
150
Profile: profile,
151
FollowStatus: followStatus,
152
Followers: followers,
···
189
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
190
}
191
192
s.pages.ReposPage(w, pages.ReposPageParams{
193
LoggedInUser: loggedInUser,
194
Repos: repos,
···
196
Card: pages.ProfileCard{
197
UserDid: ident.DID.String(),
198
UserHandle: ident.Handle.String(),
199
Profile: profile,
200
FollowStatus: followStatus,
201
Followers: followers,
···
204
})
205
}
206
207
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
208
user := s.oauth.GetUser(r)
209
···
249
}
250
251
s.updateProfile(profile, w, r)
252
}
253
254
func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
···
288
profile.PinnedRepos = pinnedRepos
289
290
s.updateProfile(profile, w, r)
291
}
292
293
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
···
352
return
353
}
354
355
+
s.notifier.UpdateProfile(r.Context(), profile)
356
357
s.pages.HxRedirect(w, "/"+user.Did)
358
}
359
360
func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
+8
-8
appview/state/reaction.go
+8
-8
appview/state/reaction.go
···
10
11
lexutil "github.com/bluesky-social/indigo/lex/util"
12
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview"
14
"tangled.sh/tangled.sh/core/appview/db"
15
"tangled.sh/tangled.sh/core/appview/pages"
16
)
17
18
func (s *State) React(w http.ResponseWriter, r *http.Request) {
···
45
switch r.Method {
46
case http.MethodPost:
47
createdAt := time.Now().Format(time.RFC3339)
48
-
rkey := appview.TID()
49
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
50
Collection: tangled.FeedReactionNSID,
51
Repo: currentUser.Did,
···
77
log.Println("created atproto record: ", resp.Uri)
78
79
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
80
-
ThreadAt: subjectUri,
81
-
Kind: reactionKind,
82
-
Count: count,
83
IsReacted: true,
84
})
85
···
115
}
116
117
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
118
-
ThreadAt: subjectUri,
119
-
Kind: reactionKind,
120
-
Count: count,
121
IsReacted: false,
122
})
123
···
10
11
lexutil "github.com/bluesky-social/indigo/lex/util"
12
"tangled.sh/tangled.sh/core/api/tangled"
13
"tangled.sh/tangled.sh/core/appview/db"
14
"tangled.sh/tangled.sh/core/appview/pages"
15
+
"tangled.sh/tangled.sh/core/tid"
16
)
17
18
func (s *State) React(w http.ResponseWriter, r *http.Request) {
···
45
switch r.Method {
46
case http.MethodPost:
47
createdAt := time.Now().Format(time.RFC3339)
48
+
rkey := tid.TID()
49
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
50
Collection: tangled.FeedReactionNSID,
51
Repo: currentUser.Did,
···
77
log.Println("created atproto record: ", resp.Uri)
78
79
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
80
+
ThreadAt: subjectUri,
81
+
Kind: reactionKind,
82
+
Count: count,
83
IsReacted: true,
84
})
85
···
115
}
116
117
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
118
+
ThreadAt: subjectUri,
119
+
Kind: reactionKind,
120
+
Count: count,
121
IsReacted: false,
122
})
123
+35
-7
appview/state/router.go
+35
-7
appview/state/router.go
···
14
"tangled.sh/tangled.sh/core/appview/pulls"
15
"tangled.sh/tangled.sh/core/appview/repo"
16
"tangled.sh/tangled.sh/core/appview/settings"
17
"tangled.sh/tangled.sh/core/appview/spindles"
18
"tangled.sh/tangled.sh/core/appview/state/userutil"
19
"tangled.sh/tangled.sh/core/log"
20
)
21
···
65
66
func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
67
r := chi.NewRouter()
68
-
69
-
// strip @ from user
70
-
r.Use(middleware.StripLeadingAt)
71
72
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
73
r.Get("/", s.Profile)
···
135
})
136
137
r.Mount("/settings", s.SettingsRouter())
138
r.Mount("/knots", s.KnotsRouter(mw))
139
r.Mount("/spindles", s.SpindlesRouter())
140
r.Mount("/", s.OAuthRouter())
141
142
r.Get("/keys/{user}", s.Keys)
143
144
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
145
s.pages.Error404(w)
···
197
return knots.Router(mw)
198
}
199
200
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
201
-
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog)
202
return issues.Router(mw)
203
}
204
205
func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {
206
-
pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog)
207
return pulls.Router(mw)
208
}
209
210
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
211
-
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.posthog, s.enforcer)
212
return repo.Router(mw)
213
}
214
215
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
216
-
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.posthog, s.enforcer)
217
return pipes.Router(mw)
218
}
···
14
"tangled.sh/tangled.sh/core/appview/pulls"
15
"tangled.sh/tangled.sh/core/appview/repo"
16
"tangled.sh/tangled.sh/core/appview/settings"
17
+
"tangled.sh/tangled.sh/core/appview/signup"
18
"tangled.sh/tangled.sh/core/appview/spindles"
19
"tangled.sh/tangled.sh/core/appview/state/userutil"
20
+
avstrings "tangled.sh/tangled.sh/core/appview/strings"
21
"tangled.sh/tangled.sh/core/log"
22
)
23
···
67
68
func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
69
r := chi.NewRouter()
70
71
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
72
r.Get("/", s.Profile)
···
134
})
135
136
r.Mount("/settings", s.SettingsRouter())
137
+
r.Mount("/strings", s.StringsRouter(mw))
138
r.Mount("/knots", s.KnotsRouter(mw))
139
r.Mount("/spindles", s.SpindlesRouter())
140
+
r.Mount("/signup", s.SignupRouter())
141
r.Mount("/", s.OAuthRouter())
142
143
r.Get("/keys/{user}", s.Keys)
144
+
r.Get("/terms", s.TermsOfService)
145
+
r.Get("/privacy", s.PrivacyPolicy)
146
147
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
148
s.pages.Error404(w)
···
200
return knots.Router(mw)
201
}
202
203
+
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
204
+
logger := log.New("strings")
205
+
206
+
strs := &avstrings.Strings{
207
+
Db: s.db,
208
+
OAuth: s.oauth,
209
+
Pages: s.pages,
210
+
Config: s.config,
211
+
Enforcer: s.enforcer,
212
+
IdResolver: s.idResolver,
213
+
Knotstream: s.knotstream,
214
+
Logger: logger,
215
+
}
216
+
217
+
return strs.Router(mw)
218
+
}
219
+
220
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
221
+
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
222
return issues.Router(mw)
223
}
224
225
func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {
226
+
pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
227
return pulls.Router(mw)
228
}
229
230
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
231
+
logger := log.New("repo")
232
+
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger)
233
return repo.Router(mw)
234
}
235
236
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
237
+
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
238
return pipes.Router(mw)
239
}
240
+
241
+
func (s *State) SignupRouter() http.Handler {
242
+
logger := log.New("signup")
243
+
244
+
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger)
245
+
return sig.Router()
246
+
}
+15
-29
appview/state/star.go
+15
-29
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
-
"github.com/posthog/posthog-go"
12
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview"
14
"tangled.sh/tangled.sh/core/appview/db"
15
"tangled.sh/tangled.sh/core/appview/pages"
16
)
17
18
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
···
39
switch r.Method {
40
case http.MethodPost:
41
createdAt := time.Now().Format(time.RFC3339)
42
-
rkey := appview.TID()
43
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
44
Collection: tangled.FeedStarNSID,
45
Repo: currentUser.Did,
···
54
log.Println("failed to create atproto record", err)
55
return
56
}
57
58
-
err = db.AddStar(s.db, currentUser.Did, subjectUri, rkey)
59
if err != nil {
60
log.Println("failed to star", err)
61
return
···
66
log.Println("failed to get star count for ", subjectUri)
67
}
68
69
-
log.Println("created atproto record: ", resp.Uri)
70
71
-
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
72
IsStarred: true,
73
RepoAt: subjectUri,
74
Stats: db.RepoStats{
···
76
},
77
})
78
79
-
if !s.config.Core.Dev {
80
-
err = s.posthog.Enqueue(posthog.Capture{
81
-
DistinctId: currentUser.Did,
82
-
Event: "star",
83
-
Properties: posthog.Properties{"repo_at": subjectUri.String()},
84
-
})
85
-
if err != nil {
86
-
log.Println("failed to enqueue posthog event:", err)
87
-
}
88
-
}
89
-
90
return
91
case http.MethodDelete:
92
// find the record in the db
···
119
return
120
}
121
122
-
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
123
IsStarred: false,
124
RepoAt: subjectUri,
125
Stats: db.RepoStats{
126
StarCount: starCount,
127
},
128
})
129
-
130
-
if !s.config.Core.Dev {
131
-
err = s.posthog.Enqueue(posthog.Capture{
132
-
DistinctId: currentUser.Did,
133
-
Event: "unstar",
134
-
Properties: posthog.Properties{"repo_at": subjectUri.String()},
135
-
})
136
-
if err != nil {
137
-
log.Println("failed to enqueue posthog event:", err)
138
-
}
139
-
}
140
141
return
142
}
···
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/db"
13
"tangled.sh/tangled.sh/core/appview/pages"
14
+
"tangled.sh/tangled.sh/core/tid"
15
)
16
17
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
···
38
switch r.Method {
39
case http.MethodPost:
40
createdAt := time.Now().Format(time.RFC3339)
41
+
rkey := tid.TID()
42
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
43
Collection: tangled.FeedStarNSID,
44
Repo: currentUser.Did,
···
53
log.Println("failed to create atproto record", err)
54
return
55
}
56
+
log.Println("created atproto record: ", resp.Uri)
57
58
+
star := &db.Star{
59
+
StarredByDid: currentUser.Did,
60
+
RepoAt: subjectUri,
61
+
Rkey: rkey,
62
+
}
63
+
64
+
err = db.AddStar(s.db, star)
65
if err != nil {
66
log.Println("failed to star", err)
67
return
···
72
log.Println("failed to get star count for ", subjectUri)
73
}
74
75
+
s.notifier.NewStar(r.Context(), star)
76
77
+
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
78
IsStarred: true,
79
RepoAt: subjectUri,
80
Stats: db.RepoStats{
···
82
},
83
})
84
85
return
86
case http.MethodDelete:
87
// find the record in the db
···
114
return
115
}
116
117
+
s.notifier.DeleteStar(r.Context(), star)
118
+
119
+
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
120
IsStarred: false,
121
RepoAt: subjectUri,
122
Stats: db.RepoStats{
123
StarCount: starCount,
124
},
125
})
126
127
return
128
}
+28
-356
appview/state/state.go
+28
-356
appview/state/state.go
···
10
"time"
11
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
-
"github.com/bluesky-social/indigo/atproto/syntax"
14
lexutil "github.com/bluesky-social/indigo/lex/util"
15
securejoin "github.com/cyphar/filepath-securejoin"
16
"github.com/go-chi/chi/v5"
···
21
"tangled.sh/tangled.sh/core/appview/cache/session"
22
"tangled.sh/tangled.sh/core/appview/config"
23
"tangled.sh/tangled.sh/core/appview/db"
24
-
"tangled.sh/tangled.sh/core/appview/idresolver"
25
"tangled.sh/tangled.sh/core/appview/oauth"
26
"tangled.sh/tangled.sh/core/appview/pages"
27
"tangled.sh/tangled.sh/core/appview/reporesolver"
28
"tangled.sh/tangled.sh/core/eventconsumer"
29
"tangled.sh/tangled.sh/core/jetstream"
30
"tangled.sh/tangled.sh/core/knotclient"
31
tlog "tangled.sh/tangled.sh/core/log"
32
"tangled.sh/tangled.sh/core/rbac"
33
)
34
35
type State struct {
36
db *db.DB
37
oauth *oauth.OAuth
38
enforcer *rbac.Enforcer
39
-
tidClock syntax.TIDClock
40
pages *pages.Pages
41
sess *session.SessionStore
42
idResolver *idresolver.Resolver
···
59
return nil, fmt.Errorf("failed to create enforcer: %w", err)
60
}
61
62
-
clock := syntax.NewTIDClock(0)
63
-
64
pgs := pages.NewPages(config)
65
66
-
res, err := idresolver.RedisResolver(config.Redis)
67
if err != nil {
68
log.Printf("failed to create redis resolver: %v", err)
69
res = idresolver.DefaultResolver()
···
93
tangled.ActorProfileNSID,
94
tangled.SpindleMemberNSID,
95
tangled.SpindleNSID,
96
},
97
nil,
98
slog.Default(),
···
131
}
132
spindlestream.Start(ctx)
133
134
state := &State{
135
d,
136
oauth,
137
enforcer,
138
-
clock,
139
pgs,
140
sess,
141
res,
···
150
return state, nil
151
}
152
153
-
func TID(c *syntax.TIDClock) string {
154
-
return c.Next().String()
155
}
156
157
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
···
198
return
199
}
200
201
-
// requires auth
202
-
// func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) {
203
-
// switch r.Method {
204
-
// case http.MethodGet:
205
-
// // list open registrations under this did
206
-
//
207
-
// return
208
-
// case http.MethodPost:
209
-
// session, err := s.oauth.Stores().Get(r, oauth.SessionName)
210
-
// if err != nil || session.IsNew {
211
-
// log.Println("unauthorized attempt to generate registration key")
212
-
// http.Error(w, "Forbidden", http.StatusUnauthorized)
213
-
// return
214
-
// }
215
-
//
216
-
// did := session.Values[oauth.SessionDid].(string)
217
-
//
218
-
// // check if domain is valid url, and strip extra bits down to just host
219
-
// domain := r.FormValue("domain")
220
-
// if domain == "" {
221
-
// http.Error(w, "Invalid form", http.StatusBadRequest)
222
-
// return
223
-
// }
224
-
//
225
-
// key, err := db.GenerateRegistrationKey(s.db, domain, did)
226
-
//
227
-
// if err != nil {
228
-
// log.Println(err)
229
-
// http.Error(w, "unable to register this domain", http.StatusNotAcceptable)
230
-
// return
231
-
// }
232
-
//
233
-
// w.Write([]byte(key))
234
-
// }
235
-
// }
236
-
237
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
238
user := chi.URLParam(r, "user")
239
user = strings.TrimPrefix(user, "@")
···
266
}
267
}
268
269
-
// create a signed request and check if a node responds to that
270
-
// func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) {
271
-
// user := s.oauth.GetUser(r)
272
-
//
273
-
// noticeId := "operation-error"
274
-
// defaultErr := "Failed to register spindle. Try again later."
275
-
// fail := func() {
276
-
// s.pages.Notice(w, noticeId, defaultErr)
277
-
// }
278
-
//
279
-
// domain := chi.URLParam(r, "domain")
280
-
// if domain == "" {
281
-
// http.Error(w, "malformed url", http.StatusBadRequest)
282
-
// return
283
-
// }
284
-
// log.Println("checking ", domain)
285
-
//
286
-
// secret, err := db.GetRegistrationKey(s.db, domain)
287
-
// if err != nil {
288
-
// log.Printf("no key found for domain %s: %s\n", domain, err)
289
-
// return
290
-
// }
291
-
//
292
-
// client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
293
-
// if err != nil {
294
-
// log.Println("failed to create client to ", domain)
295
-
// }
296
-
//
297
-
// resp, err := client.Init(user.Did)
298
-
// if err != nil {
299
-
// w.Write([]byte("no dice"))
300
-
// log.Println("domain was unreachable after 5 seconds")
301
-
// return
302
-
// }
303
-
//
304
-
// if resp.StatusCode == http.StatusConflict {
305
-
// log.Println("status conflict", resp.StatusCode)
306
-
// w.Write([]byte("already registered, sorry!"))
307
-
// return
308
-
// }
309
-
//
310
-
// if resp.StatusCode != http.StatusNoContent {
311
-
// log.Println("status nok", resp.StatusCode)
312
-
// w.Write([]byte("no dice"))
313
-
// return
314
-
// }
315
-
//
316
-
// // verify response mac
317
-
// signature := resp.Header.Get("X-Signature")
318
-
// signatureBytes, err := hex.DecodeString(signature)
319
-
// if err != nil {
320
-
// return
321
-
// }
322
-
//
323
-
// expectedMac := hmac.New(sha256.New, []byte(secret))
324
-
// expectedMac.Write([]byte("ok"))
325
-
//
326
-
// if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) {
327
-
// log.Printf("response body signature mismatch: %x\n", signatureBytes)
328
-
// return
329
-
// }
330
-
//
331
-
// tx, err := s.db.BeginTx(r.Context(), nil)
332
-
// if err != nil {
333
-
// log.Println("failed to start tx", err)
334
-
// http.Error(w, err.Error(), http.StatusInternalServerError)
335
-
// return
336
-
// }
337
-
// defer func() {
338
-
// tx.Rollback()
339
-
// err = s.enforcer.E.LoadPolicy()
340
-
// if err != nil {
341
-
// log.Println("failed to rollback policies")
342
-
// }
343
-
// }()
344
-
//
345
-
// // mark as registered
346
-
// err = db.Register(tx, domain)
347
-
// if err != nil {
348
-
// log.Println("failed to register domain", err)
349
-
// http.Error(w, err.Error(), http.StatusInternalServerError)
350
-
// return
351
-
// }
352
-
//
353
-
// // set permissions for this did as owner
354
-
// reg, err := db.RegistrationByDomain(tx, domain)
355
-
// if err != nil {
356
-
// log.Println("failed to register domain", err)
357
-
// http.Error(w, err.Error(), http.StatusInternalServerError)
358
-
// return
359
-
// }
360
-
//
361
-
// // add basic acls for this domain
362
-
// err = s.enforcer.AddKnot(domain)
363
-
// if err != nil {
364
-
// log.Println("failed to setup owner of domain", err)
365
-
// http.Error(w, err.Error(), http.StatusInternalServerError)
366
-
// return
367
-
// }
368
-
//
369
-
// // add this did as owner of this domain
370
-
// err = s.enforcer.AddKnotOwner(domain, reg.ByDid)
371
-
// if err != nil {
372
-
// log.Println("failed to setup owner of domain", err)
373
-
// http.Error(w, err.Error(), http.StatusInternalServerError)
374
-
// return
375
-
// }
376
-
//
377
-
// err = tx.Commit()
378
-
// if err != nil {
379
-
// log.Println("failed to commit changes", err)
380
-
// http.Error(w, err.Error(), http.StatusInternalServerError)
381
-
// return
382
-
// }
383
-
//
384
-
// err = s.enforcer.E.SavePolicy()
385
-
// if err != nil {
386
-
// log.Println("failed to update ACLs", err)
387
-
// http.Error(w, err.Error(), http.StatusInternalServerError)
388
-
// return
389
-
// }
390
-
//
391
-
// // add this knot to knotstream
392
-
// go s.knotstream.AddSource(
393
-
// context.Background(),
394
-
// eventconsumer.NewKnotSource(domain),
395
-
// )
396
-
//
397
-
// w.Write([]byte("check success"))
398
-
// }
399
-
400
-
// func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) {
401
-
// domain := chi.URLParam(r, "domain")
402
-
// if domain == "" {
403
-
// http.Error(w, "malformed url", http.StatusBadRequest)
404
-
// return
405
-
// }
406
-
//
407
-
// user := s.oauth.GetUser(r)
408
-
// reg, err := db.RegistrationByDomain(s.db, domain)
409
-
// if err != nil {
410
-
// w.Write([]byte("failed to pull up registration info"))
411
-
// return
412
-
// }
413
-
//
414
-
// var members []string
415
-
// if reg.Registered != nil {
416
-
// members, err = s.enforcer.GetUserByRole("server:member", domain)
417
-
// if err != nil {
418
-
// w.Write([]byte("failed to fetch member list"))
419
-
// return
420
-
// }
421
-
// }
422
-
//
423
-
// var didsToResolve []string
424
-
// for _, m := range members {
425
-
// didsToResolve = append(didsToResolve, m)
426
-
// }
427
-
// didsToResolve = append(didsToResolve, reg.ByDid)
428
-
// resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
429
-
// didHandleMap := make(map[string]string)
430
-
// for _, identity := range resolvedIds {
431
-
// if !identity.Handle.IsInvalidHandle() {
432
-
// didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
433
-
// } else {
434
-
// didHandleMap[identity.DID.String()] = identity.DID.String()
435
-
// }
436
-
// }
437
-
//
438
-
// ok, err := s.enforcer.IsKnotOwner(user.Did, domain)
439
-
// isOwner := err == nil && ok
440
-
//
441
-
// p := pages.KnotParams{
442
-
// LoggedInUser: user,
443
-
// DidHandleMap: didHandleMap,
444
-
// Registration: reg,
445
-
// Members: members,
446
-
// IsOwner: isOwner,
447
-
// }
448
-
//
449
-
// s.pages.Knot(w, p)
450
-
// }
451
-
452
-
// get knots registered by this user
453
-
// func (s *State) Knots(w http.ResponseWriter, r *http.Request) {
454
-
// // for now, this is just pubkeys
455
-
// user := s.oauth.GetUser(r)
456
-
// registrations, err := db.RegistrationsByDid(s.db, user.Did)
457
-
// if err != nil {
458
-
// log.Println(err)
459
-
// }
460
-
//
461
-
// s.pages.Knots(w, pages.KnotsParams{
462
-
// LoggedInUser: user,
463
-
// Registrations: registrations,
464
-
// })
465
-
// }
466
-
467
-
// list members of domain, requires auth and requires owner status
468
-
// func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) {
469
-
// domain := chi.URLParam(r, "domain")
470
-
// if domain == "" {
471
-
// http.Error(w, "malformed url", http.StatusBadRequest)
472
-
// return
473
-
// }
474
-
//
475
-
// // list all members for this domain
476
-
// memberDids, err := s.enforcer.GetUserByRole("server:member", domain)
477
-
// if err != nil {
478
-
// w.Write([]byte("failed to fetch member list"))
479
-
// return
480
-
// }
481
-
//
482
-
// w.Write([]byte(strings.Join(memberDids, "\n")))
483
-
// return
484
-
// }
485
-
486
-
// add member to domain, requires auth and requires invite access
487
-
// func (s *State) AddMember(w http.ResponseWriter, r *http.Request) {
488
-
// domain := chi.URLParam(r, "domain")
489
-
// if domain == "" {
490
-
// http.Error(w, "malformed url", http.StatusBadRequest)
491
-
// return
492
-
// }
493
-
//
494
-
// subjectIdentifier := r.FormValue("subject")
495
-
// if subjectIdentifier == "" {
496
-
// http.Error(w, "malformed form", http.StatusBadRequest)
497
-
// return
498
-
// }
499
-
//
500
-
// subjectIdentity, err := s.idResolver.ResolveIdent(r.Context(), subjectIdentifier)
501
-
// if err != nil {
502
-
// w.Write([]byte("failed to resolve member did to a handle"))
503
-
// return
504
-
// }
505
-
// log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain)
506
-
//
507
-
// // announce this relation into the firehose, store into owners' pds
508
-
// client, err := s.oauth.AuthorizedClient(r)
509
-
// if err != nil {
510
-
// http.Error(w, "failed to authorize client", http.StatusInternalServerError)
511
-
// return
512
-
// }
513
-
// currentUser := s.oauth.GetUser(r)
514
-
// createdAt := time.Now().Format(time.RFC3339)
515
-
// resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
516
-
// Collection: tangled.KnotMemberNSID,
517
-
// Repo: currentUser.Did,
518
-
// Rkey: appview.TID(),
519
-
// Record: &lexutil.LexiconTypeDecoder{
520
-
// Val: &tangled.KnotMember{
521
-
// Subject: subjectIdentity.DID.String(),
522
-
// Domain: domain,
523
-
// CreatedAt: createdAt,
524
-
// }},
525
-
// })
526
-
//
527
-
// // invalid record
528
-
// if err != nil {
529
-
// log.Printf("failed to create record: %s", err)
530
-
// return
531
-
// }
532
-
// log.Println("created atproto record: ", resp.Uri)
533
-
//
534
-
// secret, err := db.GetRegistrationKey(s.db, domain)
535
-
// if err != nil {
536
-
// log.Printf("no key found for domain %s: %s\n", domain, err)
537
-
// return
538
-
// }
539
-
//
540
-
// ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
541
-
// if err != nil {
542
-
// log.Println("failed to create client to ", domain)
543
-
// return
544
-
// }
545
-
//
546
-
// ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
547
-
// if err != nil {
548
-
// log.Printf("failed to make request to %s: %s", domain, err)
549
-
// return
550
-
// }
551
-
//
552
-
// if ksResp.StatusCode != http.StatusNoContent {
553
-
// w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err)))
554
-
// return
555
-
// }
556
-
//
557
-
// err = s.enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
558
-
// if err != nil {
559
-
// w.Write([]byte(fmt.Sprint("failed to add member: ", err)))
560
-
// return
561
-
// }
562
-
//
563
-
// w.Write([]byte(fmt.Sprint("added member: ", subjectIdentity.Handle.String())))
564
-
// }
565
-
566
-
// func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) {
567
-
// }
568
-
569
func validateRepoName(name string) error {
570
// check for path traversal attempts
571
if name == "." || name == ".." ||
···
664
return
665
}
666
667
-
rkey := appview.TID()
668
repo := &db.Repo{
669
Did: user.Did,
670
Name: repoName,
···
760
return
761
}
762
763
-
if !s.config.Core.Dev {
764
-
err = s.posthog.Enqueue(posthog.Capture{
765
-
DistinctId: user.Did,
766
-
Event: "new_repo",
767
-
Properties: posthog.Properties{"repo": repoName, "repo_at": repo.AtUri},
768
-
})
769
-
if err != nil {
770
-
log.Println("failed to enqueue posthog event:", err)
771
-
}
772
-
}
773
774
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
775
return
···
10
"time"
11
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
lexutil "github.com/bluesky-social/indigo/lex/util"
14
securejoin "github.com/cyphar/filepath-securejoin"
15
"github.com/go-chi/chi/v5"
···
20
"tangled.sh/tangled.sh/core/appview/cache/session"
21
"tangled.sh/tangled.sh/core/appview/config"
22
"tangled.sh/tangled.sh/core/appview/db"
23
+
"tangled.sh/tangled.sh/core/appview/notify"
24
"tangled.sh/tangled.sh/core/appview/oauth"
25
"tangled.sh/tangled.sh/core/appview/pages"
26
+
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
27
"tangled.sh/tangled.sh/core/appview/reporesolver"
28
"tangled.sh/tangled.sh/core/eventconsumer"
29
+
"tangled.sh/tangled.sh/core/idresolver"
30
"tangled.sh/tangled.sh/core/jetstream"
31
"tangled.sh/tangled.sh/core/knotclient"
32
tlog "tangled.sh/tangled.sh/core/log"
33
"tangled.sh/tangled.sh/core/rbac"
34
+
"tangled.sh/tangled.sh/core/tid"
35
)
36
37
type State struct {
38
db *db.DB
39
+
notifier notify.Notifier
40
oauth *oauth.OAuth
41
enforcer *rbac.Enforcer
42
pages *pages.Pages
43
sess *session.SessionStore
44
idResolver *idresolver.Resolver
···
61
return nil, fmt.Errorf("failed to create enforcer: %w", err)
62
}
63
64
pgs := pages.NewPages(config)
65
66
+
res, err := idresolver.RedisResolver(config.Redis.ToURL())
67
if err != nil {
68
log.Printf("failed to create redis resolver: %v", err)
69
res = idresolver.DefaultResolver()
···
93
tangled.ActorProfileNSID,
94
tangled.SpindleMemberNSID,
95
tangled.SpindleNSID,
96
+
tangled.StringNSID,
97
},
98
nil,
99
slog.Default(),
···
132
}
133
spindlestream.Start(ctx)
134
135
+
var notifiers []notify.Notifier
136
+
if !config.Core.Dev {
137
+
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
138
+
}
139
+
notifier := notify.NewMergedNotifier(notifiers...)
140
+
141
state := &State{
142
d,
143
+
notifier,
144
oauth,
145
enforcer,
146
pgs,
147
sess,
148
res,
···
157
return state, nil
158
}
159
160
+
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
161
+
user := s.oauth.GetUser(r)
162
+
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
163
+
LoggedInUser: user,
164
+
})
165
+
}
166
+
167
+
func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) {
168
+
user := s.oauth.GetUser(r)
169
+
s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{
170
+
LoggedInUser: user,
171
+
})
172
}
173
174
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
···
215
return
216
}
217
218
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
219
user := chi.URLParam(r, "user")
220
user = strings.TrimPrefix(user, "@")
···
247
}
248
}
249
250
func validateRepoName(name string) error {
251
// check for path traversal attempts
252
if name == "." || name == ".." ||
···
345
return
346
}
347
348
+
rkey := tid.TID()
349
repo := &db.Repo{
350
Did: user.Did,
351
Name: repoName,
···
441
return
442
}
443
444
+
s.notifier.NewRepo(r.Context(), repo)
445
446
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
447
return
+6
appview/state/userutil/userutil.go
+6
appview/state/userutil/userutil.go
···
51
func IsDid(s string) bool {
52
return didRegex.MatchString(s)
53
}
54
+
55
+
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
56
+
57
+
func IsValidSubdomain(name string) bool {
58
+
return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name)
59
+
}
+449
appview/strings/strings.go
+449
appview/strings/strings.go
···
···
1
+
package strings
2
+
3
+
import (
4
+
"fmt"
5
+
"log/slog"
6
+
"net/http"
7
+
"path"
8
+
"slices"
9
+
"strconv"
10
+
"strings"
11
+
"time"
12
+
13
+
"tangled.sh/tangled.sh/core/api/tangled"
14
+
"tangled.sh/tangled.sh/core/appview/config"
15
+
"tangled.sh/tangled.sh/core/appview/db"
16
+
"tangled.sh/tangled.sh/core/appview/middleware"
17
+
"tangled.sh/tangled.sh/core/appview/oauth"
18
+
"tangled.sh/tangled.sh/core/appview/pages"
19
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
20
+
"tangled.sh/tangled.sh/core/eventconsumer"
21
+
"tangled.sh/tangled.sh/core/idresolver"
22
+
"tangled.sh/tangled.sh/core/rbac"
23
+
"tangled.sh/tangled.sh/core/tid"
24
+
25
+
"github.com/bluesky-social/indigo/api/atproto"
26
+
"github.com/bluesky-social/indigo/atproto/identity"
27
+
"github.com/bluesky-social/indigo/atproto/syntax"
28
+
lexutil "github.com/bluesky-social/indigo/lex/util"
29
+
"github.com/go-chi/chi/v5"
30
+
)
31
+
32
+
type Strings struct {
33
+
Db *db.DB
34
+
OAuth *oauth.OAuth
35
+
Pages *pages.Pages
36
+
Config *config.Config
37
+
Enforcer *rbac.Enforcer
38
+
IdResolver *idresolver.Resolver
39
+
Logger *slog.Logger
40
+
Knotstream *eventconsumer.Consumer
41
+
}
42
+
43
+
func (s *Strings) Router(mw *middleware.Middleware) http.Handler {
44
+
r := chi.NewRouter()
45
+
46
+
r.
47
+
With(mw.ResolveIdent()).
48
+
Route("/{user}", func(r chi.Router) {
49
+
r.Get("/", s.dashboard)
50
+
51
+
r.Route("/{rkey}", func(r chi.Router) {
52
+
r.Get("/", s.contents)
53
+
r.Delete("/", s.delete)
54
+
r.Get("/raw", s.contents)
55
+
r.Get("/edit", s.edit)
56
+
r.Post("/edit", s.edit)
57
+
r.
58
+
With(middleware.AuthMiddleware(s.OAuth)).
59
+
Post("/comment", s.comment)
60
+
})
61
+
})
62
+
63
+
r.
64
+
With(middleware.AuthMiddleware(s.OAuth)).
65
+
Route("/new", func(r chi.Router) {
66
+
r.Get("/", s.create)
67
+
r.Post("/", s.create)
68
+
})
69
+
70
+
return r
71
+
}
72
+
73
+
func (s *Strings) contents(w http.ResponseWriter, r *http.Request) {
74
+
l := s.Logger.With("handler", "contents")
75
+
76
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
77
+
if !ok {
78
+
l.Error("malformed middleware")
79
+
w.WriteHeader(http.StatusInternalServerError)
80
+
return
81
+
}
82
+
l = l.With("did", id.DID, "handle", id.Handle)
83
+
84
+
rkey := chi.URLParam(r, "rkey")
85
+
if rkey == "" {
86
+
l.Error("malformed url, empty rkey")
87
+
w.WriteHeader(http.StatusBadRequest)
88
+
return
89
+
}
90
+
l = l.With("rkey", rkey)
91
+
92
+
strings, err := db.GetStrings(
93
+
s.Db,
94
+
db.FilterEq("did", id.DID),
95
+
db.FilterEq("rkey", rkey),
96
+
)
97
+
if err != nil {
98
+
l.Error("failed to fetch string", "err", err)
99
+
w.WriteHeader(http.StatusInternalServerError)
100
+
return
101
+
}
102
+
if len(strings) != 1 {
103
+
l.Error("incorrect number of records returned", "len(strings)", len(strings))
104
+
w.WriteHeader(http.StatusInternalServerError)
105
+
return
106
+
}
107
+
string := strings[0]
108
+
109
+
if path.Base(r.URL.Path) == "raw" {
110
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
111
+
if string.Filename != "" {
112
+
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename))
113
+
}
114
+
w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents)))
115
+
116
+
_, err = w.Write([]byte(string.Contents))
117
+
if err != nil {
118
+
l.Error("failed to write raw response", "err", err)
119
+
}
120
+
return
121
+
}
122
+
123
+
var showRendered, renderToggle bool
124
+
if markup.GetFormat(string.Filename) == markup.FormatMarkdown {
125
+
renderToggle = true
126
+
showRendered = r.URL.Query().Get("code") != "true"
127
+
}
128
+
129
+
s.Pages.SingleString(w, pages.SingleStringParams{
130
+
LoggedInUser: s.OAuth.GetUser(r),
131
+
RenderToggle: renderToggle,
132
+
ShowRendered: showRendered,
133
+
String: string,
134
+
Stats: string.Stats(),
135
+
Owner: id,
136
+
})
137
+
}
138
+
139
+
func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
140
+
l := s.Logger.With("handler", "dashboard")
141
+
142
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
143
+
if !ok {
144
+
l.Error("malformed middleware")
145
+
w.WriteHeader(http.StatusInternalServerError)
146
+
return
147
+
}
148
+
l = l.With("did", id.DID, "handle", id.Handle)
149
+
150
+
all, err := db.GetStrings(
151
+
s.Db,
152
+
db.FilterEq("did", id.DID),
153
+
)
154
+
if err != nil {
155
+
l.Error("failed to fetch strings", "err", err)
156
+
w.WriteHeader(http.StatusInternalServerError)
157
+
return
158
+
}
159
+
160
+
slices.SortFunc(all, func(a, b db.String) int {
161
+
if a.Created.After(b.Created) {
162
+
return -1
163
+
} else {
164
+
return 1
165
+
}
166
+
})
167
+
168
+
profile, err := db.GetProfile(s.Db, id.DID.String())
169
+
if err != nil {
170
+
l.Error("failed to fetch user profile", "err", err)
171
+
w.WriteHeader(http.StatusInternalServerError)
172
+
return
173
+
}
174
+
loggedInUser := s.OAuth.GetUser(r)
175
+
followStatus := db.IsNotFollowing
176
+
if loggedInUser != nil {
177
+
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
178
+
}
179
+
180
+
followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String())
181
+
if err != nil {
182
+
l.Error("failed to get follow stats", "err", err)
183
+
}
184
+
185
+
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
186
+
LoggedInUser: s.OAuth.GetUser(r),
187
+
Card: pages.ProfileCard{
188
+
UserDid: id.DID.String(),
189
+
UserHandle: id.Handle.String(),
190
+
Profile: profile,
191
+
FollowStatus: followStatus,
192
+
Followers: followers,
193
+
Following: following,
194
+
},
195
+
Strings: all,
196
+
})
197
+
}
198
+
199
+
func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
200
+
l := s.Logger.With("handler", "edit")
201
+
202
+
user := s.OAuth.GetUser(r)
203
+
204
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
205
+
if !ok {
206
+
l.Error("malformed middleware")
207
+
w.WriteHeader(http.StatusInternalServerError)
208
+
return
209
+
}
210
+
l = l.With("did", id.DID, "handle", id.Handle)
211
+
212
+
rkey := chi.URLParam(r, "rkey")
213
+
if rkey == "" {
214
+
l.Error("malformed url, empty rkey")
215
+
w.WriteHeader(http.StatusBadRequest)
216
+
return
217
+
}
218
+
l = l.With("rkey", rkey)
219
+
220
+
// get the string currently being edited
221
+
all, err := db.GetStrings(
222
+
s.Db,
223
+
db.FilterEq("did", id.DID),
224
+
db.FilterEq("rkey", rkey),
225
+
)
226
+
if err != nil {
227
+
l.Error("failed to fetch string", "err", err)
228
+
w.WriteHeader(http.StatusInternalServerError)
229
+
return
230
+
}
231
+
if len(all) != 1 {
232
+
l.Error("incorrect number of records returned", "len(strings)", len(all))
233
+
w.WriteHeader(http.StatusInternalServerError)
234
+
return
235
+
}
236
+
first := all[0]
237
+
238
+
// verify that the logged in user owns this string
239
+
if user.Did != id.DID.String() {
240
+
l.Error("unauthorized request", "expected", id.DID, "got", user.Did)
241
+
w.WriteHeader(http.StatusUnauthorized)
242
+
return
243
+
}
244
+
245
+
switch r.Method {
246
+
case http.MethodGet:
247
+
// return the form with prefilled fields
248
+
s.Pages.PutString(w, pages.PutStringParams{
249
+
LoggedInUser: s.OAuth.GetUser(r),
250
+
Action: "edit",
251
+
String: first,
252
+
})
253
+
case http.MethodPost:
254
+
fail := func(msg string, err error) {
255
+
l.Error(msg, "err", err)
256
+
s.Pages.Notice(w, "error", msg)
257
+
}
258
+
259
+
filename := r.FormValue("filename")
260
+
if filename == "" {
261
+
fail("Empty filename.", nil)
262
+
return
263
+
}
264
+
if !strings.Contains(filename, ".") {
265
+
// TODO: make this a htmx form validation
266
+
fail("No extension provided for filename.", nil)
267
+
return
268
+
}
269
+
270
+
content := r.FormValue("content")
271
+
if content == "" {
272
+
fail("Empty contents.", nil)
273
+
return
274
+
}
275
+
276
+
description := r.FormValue("description")
277
+
278
+
// construct new string from form values
279
+
entry := db.String{
280
+
Did: first.Did,
281
+
Rkey: first.Rkey,
282
+
Filename: filename,
283
+
Description: description,
284
+
Contents: content,
285
+
Created: first.Created,
286
+
}
287
+
288
+
record := entry.AsRecord()
289
+
290
+
client, err := s.OAuth.AuthorizedClient(r)
291
+
if err != nil {
292
+
fail("Failed to create record.", err)
293
+
return
294
+
}
295
+
296
+
// first replace the existing record in the PDS
297
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
298
+
if err != nil {
299
+
fail("Failed to updated existing record.", err)
300
+
return
301
+
}
302
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
303
+
Collection: tangled.StringNSID,
304
+
Repo: entry.Did.String(),
305
+
Rkey: entry.Rkey,
306
+
SwapRecord: ex.Cid,
307
+
Record: &lexutil.LexiconTypeDecoder{
308
+
Val: &record,
309
+
},
310
+
})
311
+
if err != nil {
312
+
fail("Failed to updated existing record.", err)
313
+
return
314
+
}
315
+
l := l.With("aturi", resp.Uri)
316
+
l.Info("edited string")
317
+
318
+
// if that went okay, updated the db
319
+
if err = db.AddString(s.Db, entry); err != nil {
320
+
fail("Failed to update string.", err)
321
+
return
322
+
}
323
+
324
+
// if that went okay, redir to the string
325
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
326
+
}
327
+
328
+
}
329
+
330
+
func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
331
+
l := s.Logger.With("handler", "create")
332
+
user := s.OAuth.GetUser(r)
333
+
334
+
switch r.Method {
335
+
case http.MethodGet:
336
+
s.Pages.PutString(w, pages.PutStringParams{
337
+
LoggedInUser: s.OAuth.GetUser(r),
338
+
Action: "new",
339
+
})
340
+
case http.MethodPost:
341
+
fail := func(msg string, err error) {
342
+
l.Error(msg, "err", err)
343
+
s.Pages.Notice(w, "error", msg)
344
+
}
345
+
346
+
filename := r.FormValue("filename")
347
+
if filename == "" {
348
+
fail("Empty filename.", nil)
349
+
return
350
+
}
351
+
if !strings.Contains(filename, ".") {
352
+
// TODO: make this a htmx form validation
353
+
fail("No extension provided for filename.", nil)
354
+
return
355
+
}
356
+
357
+
content := r.FormValue("content")
358
+
if content == "" {
359
+
fail("Empty contents.", nil)
360
+
return
361
+
}
362
+
363
+
description := r.FormValue("description")
364
+
365
+
string := db.String{
366
+
Did: syntax.DID(user.Did),
367
+
Rkey: tid.TID(),
368
+
Filename: filename,
369
+
Description: description,
370
+
Contents: content,
371
+
Created: time.Now(),
372
+
}
373
+
374
+
record := string.AsRecord()
375
+
376
+
client, err := s.OAuth.AuthorizedClient(r)
377
+
if err != nil {
378
+
fail("Failed to create record.", err)
379
+
return
380
+
}
381
+
382
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
383
+
Collection: tangled.StringNSID,
384
+
Repo: user.Did,
385
+
Rkey: string.Rkey,
386
+
Record: &lexutil.LexiconTypeDecoder{
387
+
Val: &record,
388
+
},
389
+
})
390
+
if err != nil {
391
+
fail("Failed to create record.", err)
392
+
return
393
+
}
394
+
l := l.With("aturi", resp.Uri)
395
+
l.Info("created record")
396
+
397
+
// insert into DB
398
+
if err = db.AddString(s.Db, string); err != nil {
399
+
fail("Failed to create string.", err)
400
+
return
401
+
}
402
+
403
+
// successful
404
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
405
+
}
406
+
}
407
+
408
+
func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
409
+
l := s.Logger.With("handler", "create")
410
+
user := s.OAuth.GetUser(r)
411
+
fail := func(msg string, err error) {
412
+
l.Error(msg, "err", err)
413
+
s.Pages.Notice(w, "error", msg)
414
+
}
415
+
416
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
417
+
if !ok {
418
+
l.Error("malformed middleware")
419
+
w.WriteHeader(http.StatusInternalServerError)
420
+
return
421
+
}
422
+
l = l.With("did", id.DID, "handle", id.Handle)
423
+
424
+
rkey := chi.URLParam(r, "rkey")
425
+
if rkey == "" {
426
+
l.Error("malformed url, empty rkey")
427
+
w.WriteHeader(http.StatusBadRequest)
428
+
return
429
+
}
430
+
431
+
if user.Did != id.DID.String() {
432
+
fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
433
+
return
434
+
}
435
+
436
+
if err := db.DeleteString(
437
+
s.Db,
438
+
db.FilterEq("did", user.Did),
439
+
db.FilterEq("rkey", rkey),
440
+
); err != nil {
441
+
fail("Failed to delete string.", err)
442
+
return
443
+
}
444
+
445
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
446
+
}
447
+
448
+
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
449
+
}
-11
appview/tid.go
-11
appview/tid.go
+15
appview/xrpcclient/xrpc.go
+15
appview/xrpcclient/xrpc.go
···
87
88
return &out, nil
89
}
90
+
91
+
func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) {
92
+
var out atproto.ServerGetServiceAuth_Output
93
+
94
+
params := map[string]interface{}{
95
+
"aud": aud,
96
+
"exp": exp,
97
+
"lxm": lxm,
98
+
}
99
+
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil {
100
+
return nil, err
101
+
}
102
+
103
+
return &out, nil
104
+
}
+33
-4
avatar/src/index.js
+33
-4
avatar/src/index.js
···
1
export default {
2
async fetch(request, env) {
3
const url = new URL(request.url);
4
const { pathname, searchParams } = url;
5
···
60
const profile = await profileResponse.json();
61
const avatar = profile.avatar;
62
63
-
if (!avatar) {
64
-
return new Response(`avatar not found for ${actor}.`, { status: 404 });
65
}
66
67
// Resize if requested
68
let avatarResponse;
69
if (resizeToTiny) {
70
-
avatarResponse = await fetch(avatar, {
71
cf: {
72
image: {
73
width: 32,
···
78
},
79
});
80
} else {
81
-
avatarResponse = await fetch(avatar);
82
}
83
84
if (!avatarResponse.ok) {
···
1
export default {
2
async fetch(request, env) {
3
+
// Helper function to generate a color from a string
4
+
const stringToColor = (str) => {
5
+
let hash = 0;
6
+
for (let i = 0; i < str.length; i++) {
7
+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
8
+
}
9
+
let color = "#";
10
+
for (let i = 0; i < 3; i++) {
11
+
const value = (hash >> (i * 8)) & 0xff;
12
+
color += ("00" + value.toString(16)).substr(-2);
13
+
}
14
+
return color;
15
+
};
16
+
17
const url = new URL(request.url);
18
const { pathname, searchParams } = url;
19
···
74
const profile = await profileResponse.json();
75
const avatar = profile.avatar;
76
77
+
let avatarUrl = profile.avatar;
78
+
79
+
if (!avatarUrl) {
80
+
// Generate a random color based on the actor string
81
+
const bgColor = stringToColor(actor);
82
+
const size = resizeToTiny ? 32 : 128;
83
+
const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bgColor}"/></svg>`;
84
+
const svgData = new TextEncoder().encode(svg);
85
+
86
+
response = new Response(svgData, {
87
+
headers: {
88
+
"Content-Type": "image/svg+xml",
89
+
"Cache-Control": "public, max-age=43200",
90
+
},
91
+
});
92
+
await cache.put(cacheKey, response.clone());
93
+
return response;
94
}
95
96
// Resize if requested
97
let avatarResponse;
98
if (resizeToTiny) {
99
+
avatarResponse = await fetch(avatarUrl, {
100
cf: {
101
image: {
102
width: 32,
···
107
},
108
});
109
} else {
110
+
avatarResponse = await fetch(avatarUrl);
111
}
112
113
if (!avatarResponse.ok) {
+2
cmd/gen.go
+2
cmd/gen.go
···
40
tangled.PublicKey{},
41
tangled.Repo{},
42
tangled.RepoArtifact{},
43
+
tangled.RepoCollaborator{},
44
tangled.RepoIssue{},
45
tangled.RepoIssueComment{},
46
tangled.RepoIssueState{},
···
50
tangled.RepoPullStatus{},
51
tangled.Spindle{},
52
tangled.SpindleMember{},
53
+
tangled.String{},
54
); err != nil {
55
panic(err)
56
}
+46
-3
docs/hacking.md
+46
-3
docs/hacking.md
···
32
nix run .#watch-tailwind
33
```
34
35
## running a knot
36
37
An end-to-end knot setup requires setting up a machine with
···
39
quite cumbersome. 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 knot 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:
···
71
git remote add local-dev git@nixos-shell:user/repo
72
git push local-dev main
73
```
···
32
nix run .#watch-tailwind
33
```
34
35
+
To authenticate with the appview, you will need redis and
36
+
OAUTH JWKs to be setup:
37
+
38
+
```
39
+
# oauth jwks should already be setup by the nix devshell:
40
+
echo $TANGLED_OAUTH_JWKS
41
+
{"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"}
42
+
43
+
# if not, you can set it up yourself:
44
+
go build -o genjwks.out ./cmd/genjwks
45
+
export TANGLED_OAUTH_JWKS="$(./genjwks.out)"
46
+
47
+
# run redis in at a new shell to store oauth sessions
48
+
redis-server
49
+
```
50
+
51
## running a knot
52
53
An end-to-end knot setup requires setting up a machine with
···
55
quite cumbersome. So the nix flake provides a
56
`nixosConfiguration` to do so.
57
58
+
To begin, head to `http://localhost:3000/knots` in the browser
59
+
and generate a knot secret. Replace the existing secret in
60
+
`nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated
61
+
secret.
62
63
You can now start a lightweight NixOS VM using
64
`nixos-shell` like so:
···
88
git remote add local-dev git@nixos-shell:user/repo
89
git push local-dev main
90
```
91
+
92
+
## running a spindle
93
+
94
+
Be sure to change the `owner` field for the spindle in
95
+
`nix/vm.nix` to your own DID. The above VM should already
96
+
be running a spindle on `localhost:6555`. You can head to
97
+
the spindle dashboard on `http://localhost:3000/spindles`,
98
+
and register a spindle with hostname `localhost:6555`. It
99
+
should instantly be verified. You can then configure each
100
+
repository to use this spindle and run CI jobs.
101
+
102
+
Of interest when debugging spindles:
103
+
104
+
```
105
+
# service logs from journald:
106
+
journalctl -xeu spindle
107
+
108
+
# CI job logs from disk:
109
+
ls /var/log/spindle
110
+
111
+
# debugging spindle db:
112
+
sqlite3 /var/lib/spindle/spindle.db
113
+
114
+
# litecli has a nicer REPL interface:
115
+
litecli /var/lib/spindle/spindle.db
116
+
```
+12
docs/knot-hosting.md
+12
docs/knot-hosting.md
···
191
```
192
193
Make sure to restart your SSH server!
194
+
195
+
#### MOTD (message of the day)
196
+
197
+
To configure the MOTD used ("Welcome to this knot!" by default), edit the
198
+
`/home/git/motd` file:
199
+
200
+
```
201
+
printf "Hi from this knot!\n" > /home/git/motd
202
+
```
203
+
204
+
Note that you should add a newline at the end if setting a non-empty message
205
+
since the knot won't do this for you.
+4
-3
docs/spindle/architecture.md
+4
-3
docs/spindle/architecture.md
···
13
14
### the engine
15
16
-
At present, the only supported backend is Docker. Spindle executes each step in
17
-
the pipeline in a fresh container, with state persisted across steps within the
18
-
`/tangled/workspace` directory.
19
20
The base image for the container is constructed on the fly using
21
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
···
13
14
### the engine
15
16
+
At present, the only supported backend is Docker (and Podman, if Docker
17
+
compatibility is enabled, so that `/run/docker.sock` is created). Spindle
18
+
executes each step in the pipeline in a fresh container, with state persisted
19
+
across steps within the `/tangled/workspace` directory.
20
21
The base image for the container is constructed on the fly using
22
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
+285
docs/spindle/openbao.md
+285
docs/spindle/openbao.md
···
···
1
+
# spindle secrets with openbao
2
+
3
+
This document covers setting up Spindle to use OpenBao for secrets
4
+
management via OpenBao Proxy instead of the default SQLite backend.
5
+
6
+
## overview
7
+
8
+
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
9
+
authentication automatically using AppRole credentials, while Spindle
10
+
connects to the local proxy instead of directly to the OpenBao server.
11
+
12
+
This approach provides better security, automatic token renewal, and
13
+
simplified application code.
14
+
15
+
## installation
16
+
17
+
Install OpenBao from nixpkgs:
18
+
19
+
```bash
20
+
nix shell nixpkgs#openbao # for a local server
21
+
```
22
+
23
+
## setup
24
+
25
+
The setup process can is documented for both local development and production.
26
+
27
+
### local development
28
+
29
+
Start OpenBao in dev mode:
30
+
31
+
```bash
32
+
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
33
+
```
34
+
35
+
This starts OpenBao on `http://localhost:8201` with a root token.
36
+
37
+
Set up environment for bao CLI:
38
+
39
+
```bash
40
+
export BAO_ADDR=http://localhost:8200
41
+
export BAO_TOKEN=root
42
+
```
43
+
44
+
### production
45
+
46
+
You would typically use a systemd service with a configuration file. Refer to
47
+
[@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be
48
+
achieved using Nix.
49
+
50
+
Then, initialize the bao server:
51
+
```bash
52
+
bao operator init -key-shares=1 -key-threshold=1
53
+
```
54
+
55
+
This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up:
56
+
```bash
57
+
bao operator unseal <unseal_key>
58
+
```
59
+
60
+
All steps below remain the same across both dev and production setups.
61
+
62
+
### configure openbao server
63
+
64
+
Create the spindle KV mount:
65
+
66
+
```bash
67
+
bao secrets enable -path=spindle -version=2 kv
68
+
```
69
+
70
+
Set up AppRole authentication and policy:
71
+
72
+
Create a policy file `spindle-policy.hcl`:
73
+
74
+
```hcl
75
+
# Full access to spindle KV v2 data
76
+
path "spindle/data/*" {
77
+
capabilities = ["create", "read", "update", "delete"]
78
+
}
79
+
80
+
# Access to metadata for listing and management
81
+
path "spindle/metadata/*" {
82
+
capabilities = ["list", "read", "delete", "update"]
83
+
}
84
+
85
+
# Allow listing at root level
86
+
path "spindle/" {
87
+
capabilities = ["list"]
88
+
}
89
+
90
+
# Required for connection testing and health checks
91
+
path "auth/token/lookup-self" {
92
+
capabilities = ["read"]
93
+
}
94
+
```
95
+
96
+
Apply the policy and create an AppRole:
97
+
98
+
```bash
99
+
bao policy write spindle-policy spindle-policy.hcl
100
+
bao auth enable approle
101
+
bao write auth/approle/role/spindle \
102
+
token_policies="spindle-policy" \
103
+
token_ttl=1h \
104
+
token_max_ttl=4h \
105
+
bind_secret_id=true \
106
+
secret_id_ttl=0 \
107
+
secret_id_num_uses=0
108
+
```
109
+
110
+
Get the credentials:
111
+
112
+
```bash
113
+
# Get role ID (static)
114
+
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
115
+
116
+
# Generate secret ID
117
+
SECRET_ID=$(bao write -field=secret_id auth/approle/role/spindle/secret-id)
118
+
119
+
echo "Role ID: $ROLE_ID"
120
+
echo "Secret ID: $SECRET_ID"
121
+
```
122
+
123
+
### create proxy configuration
124
+
125
+
Create the credential files:
126
+
127
+
```bash
128
+
# Create directory for OpenBao files
129
+
mkdir -p /tmp/openbao
130
+
131
+
# Save credentials
132
+
echo "$ROLE_ID" > /tmp/openbao/role-id
133
+
echo "$SECRET_ID" > /tmp/openbao/secret-id
134
+
chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
135
+
```
136
+
137
+
Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
138
+
139
+
```hcl
140
+
# OpenBao server connection
141
+
vault {
142
+
address = "http://localhost:8200"
143
+
}
144
+
145
+
# Auto-Auth using AppRole
146
+
auto_auth {
147
+
method "approle" {
148
+
mount_path = "auth/approle"
149
+
config = {
150
+
role_id_file_path = "/tmp/openbao/role-id"
151
+
secret_id_file_path = "/tmp/openbao/secret-id"
152
+
}
153
+
}
154
+
155
+
# Optional: write token to file for debugging
156
+
sink "file" {
157
+
config = {
158
+
path = "/tmp/openbao/token"
159
+
mode = 0640
160
+
}
161
+
}
162
+
}
163
+
164
+
# Proxy listener for Spindle
165
+
listener "tcp" {
166
+
address = "127.0.0.1:8201"
167
+
tls_disable = true
168
+
}
169
+
170
+
# Enable API proxy with auto-auth token
171
+
api_proxy {
172
+
use_auto_auth_token = true
173
+
}
174
+
175
+
# Enable response caching
176
+
cache {
177
+
use_auto_auth_token = true
178
+
}
179
+
180
+
# Logging
181
+
log_level = "info"
182
+
```
183
+
184
+
### start the proxy
185
+
186
+
Start OpenBao Proxy:
187
+
188
+
```bash
189
+
bao proxy -config=/tmp/openbao/proxy.hcl
190
+
```
191
+
192
+
The proxy will authenticate with OpenBao and start listening on
193
+
`127.0.0.1:8201`.
194
+
195
+
### configure spindle
196
+
197
+
Set these environment variables for Spindle:
198
+
199
+
```bash
200
+
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
201
+
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
202
+
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
203
+
```
204
+
205
+
Start Spindle:
206
+
207
+
Spindle will now connect to the local proxy, which handles all
208
+
authentication automatically.
209
+
210
+
## production setup for proxy
211
+
212
+
For production, you'll want to run the proxy as a service:
213
+
214
+
Place your production configuration in `/etc/openbao/proxy.hcl` with
215
+
proper TLS settings for the vault connection.
216
+
217
+
## verifying setup
218
+
219
+
Test the proxy directly:
220
+
221
+
```bash
222
+
# Check proxy health
223
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
224
+
225
+
# Test token lookup through proxy
226
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
227
+
```
228
+
229
+
Test OpenBao operations through the server:
230
+
231
+
```bash
232
+
# List all secrets
233
+
bao kv list spindle/
234
+
235
+
# Add a test secret via Spindle API, then check it exists
236
+
bao kv list spindle/repos/
237
+
238
+
# Get a specific secret
239
+
bao kv get spindle/repos/your_repo_path/SECRET_NAME
240
+
```
241
+
242
+
## how it works
243
+
244
+
- Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201)
245
+
- The proxy authenticates with OpenBao using AppRole credentials
246
+
- All Spindle requests go through the proxy, which injects authentication tokens
247
+
- Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}`
248
+
- Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo`
249
+
- The proxy handles all token renewal automatically
250
+
- Spindle no longer manages tokens or authentication directly
251
+
252
+
## troubleshooting
253
+
254
+
**Connection refused**: Check that the OpenBao Proxy is running and
255
+
listening on the configured address.
256
+
257
+
**403 errors**: Verify the AppRole credentials are correct and the policy
258
+
has the necessary permissions.
259
+
260
+
**404 route errors**: The spindle KV mount probably doesn't exist - run
261
+
the mount creation step again.
262
+
263
+
**Proxy authentication failures**: Check the proxy logs and verify the
264
+
role-id and secret-id files are readable and contain valid credentials.
265
+
266
+
**Secret not found after writing**: This can indicate policy permission
267
+
issues. Verify the policy includes both `spindle/data/*` and
268
+
`spindle/metadata/*` paths with appropriate capabilities.
269
+
270
+
Check proxy logs:
271
+
272
+
```bash
273
+
# If running as systemd service
274
+
journalctl -u openbao-proxy -f
275
+
276
+
# If running directly, check the console output
277
+
```
278
+
279
+
Test AppRole authentication manually:
280
+
281
+
```bash
282
+
bao write auth/approle/login \
283
+
role_id="$(cat /tmp/openbao/role-id)" \
284
+
secret_id="$(cat /tmp/openbao/secret-id)"
285
+
```
+7
docs/spindle/pipeline.md
+7
docs/spindle/pipeline.md
···
57
depth: 50
58
submodules: true
59
```
60
+
61
+
## git push options
62
+
63
+
These are push options that can be used with the `--push-option (-o)` flag of git push:
64
+
65
+
- `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push.
66
+
- `skip-ci`, `ci-skip`: skips triggering the CI pipeline.
+22
-22
flake.lock
+22
-22
flake.lock
···
1
{
2
"nodes": {
3
"gitignore": {
4
"inputs": {
5
"nixpkgs": [
···
17
"original": {
18
"owner": "hercules-ci",
19
"repo": "gitignore.nix",
20
-
"type": "github"
21
-
}
22
-
},
23
-
"flake-utils": {
24
-
"inputs": {
25
-
"systems": "systems"
26
-
},
27
-
"locked": {
28
-
"lastModified": 1694529238,
29
-
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
30
-
"owner": "numtide",
31
-
"repo": "flake-utils",
32
-
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
33
-
"type": "github"
34
-
},
35
-
"original": {
36
-
"owner": "numtide",
37
-
"repo": "flake-utils",
38
"type": "github"
39
}
40
},
···
128
"lucide-src": {
129
"flake": false,
130
"locked": {
131
-
"lastModified": 1742302029,
132
-
"narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=",
133
"type": "tarball",
134
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
135
},
136
"original": {
137
"type": "tarball",
138
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
139
}
140
},
141
"nixpkgs": {
···
1
{
2
"nodes": {
3
+
"flake-utils": {
4
+
"inputs": {
5
+
"systems": "systems"
6
+
},
7
+
"locked": {
8
+
"lastModified": 1694529238,
9
+
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
10
+
"owner": "numtide",
11
+
"repo": "flake-utils",
12
+
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
13
+
"type": "github"
14
+
},
15
+
"original": {
16
+
"owner": "numtide",
17
+
"repo": "flake-utils",
18
+
"type": "github"
19
+
}
20
+
},
21
"gitignore": {
22
"inputs": {
23
"nixpkgs": [
···
35
"original": {
36
"owner": "hercules-ci",
37
"repo": "gitignore.nix",
38
"type": "github"
39
}
40
},
···
128
"lucide-src": {
129
"flake": false,
130
"locked": {
131
+
"lastModified": 1754044466,
132
+
"narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=",
133
"type": "tarball",
134
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
135
},
136
"original": {
137
"type": "tarball",
138
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
139
}
140
},
141
"nixpkgs": {
+28
-4
flake.nix
+28
-4
flake.nix
···
22
flake = false;
23
};
24
lucide-src = {
25
-
url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip";
26
flake = false;
27
};
28
inter-fonts-src = {
···
190
};
191
});
192
193
-
nixosModules.appview = import ./nix/modules/appview.nix {inherit self;};
194
-
nixosModules.knot = import ./nix/modules/knot.nix {inherit self;};
195
-
nixosModules.spindle = import ./nix/modules/spindle.nix {inherit self;};
196
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
197
};
198
}
···
22
flake = false;
23
};
24
lucide-src = {
25
+
url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip";
26
flake = false;
27
};
28
inter-fonts-src = {
···
190
};
191
});
192
193
+
nixosModules.appview = {
194
+
lib,
195
+
pkgs,
196
+
...
197
+
}: {
198
+
imports = [./nix/modules/appview.nix];
199
+
200
+
services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
201
+
};
202
+
nixosModules.knot = {
203
+
lib,
204
+
pkgs,
205
+
...
206
+
}: {
207
+
imports = [./nix/modules/knot.nix];
208
+
209
+
services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
210
+
};
211
+
nixosModules.spindle = {
212
+
lib,
213
+
pkgs,
214
+
...
215
+
}: {
216
+
imports = [./nix/modules/spindle.nix];
217
+
218
+
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
219
+
};
220
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
221
};
222
}
+54
-34
go.mod
+54
-34
go.mod
···
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-20250520232546-236dd575c91e
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
github.com/carlmjohnson/versioninfo v0.22.5
14
github.com/casbin/casbin/v2 v2.103.0
15
github.com/cyphar/filepath-securejoin v0.4.1
16
github.com/dgraph-io/ristretto v0.2.0
17
github.com/docker/docker v28.2.2+incompatible
···
22
github.com/go-git/go-git/v5 v5.14.0
23
github.com/google/uuid v1.6.0
24
github.com/gorilla/sessions v1.4.0
25
-
github.com/gorilla/websocket v1.5.3
26
github.com/hiddeco/sshsig v0.2.0
27
github.com/hpcloud/tail v1.0.0
28
github.com/ipfs/go-cid v0.5.0
29
github.com/lestrrat-go/jwx/v2 v2.1.6
30
github.com/mattn/go-sqlite3 v1.14.24
31
github.com/microcosm-cc/bluemonday v1.0.27
32
github.com/posthog/posthog-go v1.5.5
33
-
github.com/redis/go-redis/v9 v9.3.0
34
github.com/resend/resend-go/v2 v2.15.0
35
github.com/sethvargo/go-envconfig v1.1.0
36
github.com/stretchr/testify v1.10.0
37
github.com/urfave/cli/v3 v3.3.3
38
github.com/whyrusleeping/cbor-gen v0.3.1
39
github.com/yuin/goldmark v1.4.13
40
-
golang.org/x/crypto v0.38.0
41
-
golang.org/x/net v0.40.0
42
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
43
gopkg.in/yaml.v3 v3.0.1
44
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421
45
)
46
47
require (
48
dario.cat/mergo v1.0.1 // indirect
49
github.com/Microsoft/go-winio v0.6.2 // indirect
50
-
github.com/ProtonMail/go-crypto v1.2.0 // indirect
51
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
52
-
github.com/avast/retry-go/v4 v4.6.1 // indirect
53
github.com/aymerick/douceur v0.2.0 // indirect
54
github.com/beorn7/perks v1.0.1 // indirect
55
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
56
github.com/casbin/govaluate v1.3.0 // indirect
57
github.com/cespare/xxhash/v2 v2.3.0 // indirect
58
-
github.com/cloudflare/circl v1.6.0 // indirect
59
github.com/containerd/errdefs v1.0.0 // indirect
60
github.com/containerd/errdefs/pkg v0.3.0 // indirect
61
github.com/containerd/log v0.1.0 // indirect
···
68
github.com/docker/go-units v0.5.0 // indirect
69
github.com/emirpasic/gods v1.18.1 // indirect
70
github.com/felixge/httpsnoop v1.0.4 // indirect
71
github.com/go-enry/go-oniguruma v1.2.1 // indirect
72
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
73
github.com/go-git/go-billy/v5 v5.6.2 // indirect
74
-
github.com/go-logr/logr v1.4.2 // indirect
75
github.com/go-logr/stdr v1.2.2 // indirect
76
github.com/go-redis/cache/v9 v9.0.0 // indirect
77
github.com/goccy/go-json v0.10.5 // indirect
78
github.com/gogo/protobuf v1.3.2 // indirect
79
-
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
80
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
81
github.com/gorilla/css v1.0.1 // indirect
82
github.com/gorilla/securecookie v1.1.2 // indirect
83
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
84
-
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
85
github.com/hashicorp/golang-lru v1.0.2 // indirect
86
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
87
github.com/ipfs/bbloom v0.0.4 // indirect
88
-
github.com/ipfs/boxo v0.30.0 // indirect
89
-
github.com/ipfs/go-block-format v0.2.1 // indirect
90
github.com/ipfs/go-datastore v0.8.2 // indirect
91
github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
92
github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect
93
-
github.com/ipfs/go-ipld-cbor v0.2.0 // indirect
94
-
github.com/ipfs/go-ipld-format v0.6.1 // indirect
95
github.com/ipfs/go-log v1.0.5 // indirect
96
github.com/ipfs/go-log/v2 v2.6.0 // indirect
97
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
98
github.com/kevinburke/ssh_config v1.2.0 // indirect
99
github.com/klauspost/compress v1.18.0 // indirect
100
-
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
101
-
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
102
github.com/lestrrat-go/httpcc v1.0.1 // indirect
103
github.com/lestrrat-go/httprc v1.0.6 // indirect
104
github.com/lestrrat-go/iter v1.0.2 // indirect
105
github.com/lestrrat-go/option v1.0.1 // indirect
106
github.com/mattn/go-isatty v0.0.20 // indirect
107
github.com/minio/sha256-simd v1.0.1 // indirect
108
github.com/moby/docker-image-spec v1.3.1 // indirect
109
github.com/moby/sys/atomicwriter v0.1.0 // indirect
110
github.com/moby/term v0.5.2 // indirect
···
116
github.com/multiformats/go-multihash v0.2.3 // indirect
117
github.com/multiformats/go-varint v0.0.7 // indirect
118
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
119
github.com/opencontainers/go-digest v1.0.0 // indirect
120
github.com/opencontainers/image-spec v1.1.1 // indirect
121
-
github.com/opentracing/opentracing-go v1.2.0 // indirect
122
github.com/pjbgf/sha1cd v0.3.2 // indirect
123
github.com/pkg/errors v0.9.1 // indirect
124
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
125
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
126
github.com/prometheus/client_golang v1.22.0 // indirect
127
github.com/prometheus/client_model v0.6.2 // indirect
128
-
github.com/prometheus/common v0.63.0 // indirect
129
github.com/prometheus/procfs v0.16.1 // indirect
130
github.com/segmentio/asm v1.2.0 // indirect
131
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
132
github.com/spaolacci/murmur3 v1.1.0 // indirect
···
136
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
137
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
138
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
139
-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
140
-
go.opentelemetry.io/otel v1.36.0 // indirect
141
-
go.opentelemetry.io/otel/metric v1.36.0 // indirect
142
-
go.opentelemetry.io/otel/trace v1.36.0 // indirect
143
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
144
go.uber.org/atomic v1.11.0 // indirect
145
go.uber.org/multierr v1.11.0 // indirect
146
go.uber.org/zap v1.27.0 // indirect
147
-
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
148
-
golang.org/x/sync v0.14.0 // indirect
149
-
golang.org/x/sys v0.33.0 // indirect
150
-
golang.org/x/time v0.8.0 // indirect
151
-
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
152
-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
153
-
google.golang.org/grpc v1.72.1 // indirect
154
google.golang.org/protobuf v1.36.6 // indirect
155
gopkg.in/fsnotify.v1 v1.4.7 // indirect
156
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
···
1
module tangled.sh/tangled.sh/core
2
3
+
go 1.24.4
4
5
require (
6
github.com/Blank-Xu/sql-adapter v1.1.1
7
+
github.com/alecthomas/assert/v2 v2.11.0
8
github.com/alecthomas/chroma/v2 v2.15.0
9
+
github.com/avast/retry-go/v4 v4.6.1
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
+
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
github.com/carlmjohnson/versioninfo v0.22.5
14
github.com/casbin/casbin/v2 v2.103.0
15
+
github.com/cloudflare/cloudflare-go v0.115.0
16
github.com/cyphar/filepath-securejoin v0.4.1
17
github.com/dgraph-io/ristretto v0.2.0
18
github.com/docker/docker v28.2.2+incompatible
···
23
github.com/go-git/go-git/v5 v5.14.0
24
github.com/google/uuid v1.6.0
25
github.com/gorilla/sessions v1.4.0
26
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
27
github.com/hiddeco/sshsig v0.2.0
28
github.com/hpcloud/tail v1.0.0
29
github.com/ipfs/go-cid v0.5.0
30
github.com/lestrrat-go/jwx/v2 v2.1.6
31
github.com/mattn/go-sqlite3 v1.14.24
32
github.com/microcosm-cc/bluemonday v1.0.27
33
+
github.com/openbao/openbao/api/v2 v2.3.0
34
github.com/posthog/posthog-go v1.5.5
35
+
github.com/redis/go-redis/v9 v9.7.3
36
github.com/resend/resend-go/v2 v2.15.0
37
github.com/sethvargo/go-envconfig v1.1.0
38
github.com/stretchr/testify v1.10.0
39
github.com/urfave/cli/v3 v3.3.3
40
github.com/whyrusleeping/cbor-gen v0.3.1
41
github.com/yuin/goldmark v1.4.13
42
+
golang.org/x/crypto v0.40.0
43
+
golang.org/x/net v0.42.0
44
+
golang.org/x/sync v0.16.0
45
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
46
gopkg.in/yaml.v3 v3.0.1
47
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1
48
)
49
50
require (
51
dario.cat/mergo v1.0.1 // indirect
52
github.com/Microsoft/go-winio v0.6.2 // indirect
53
+
github.com/ProtonMail/go-crypto v1.3.0 // indirect
54
+
github.com/alecthomas/repr v0.4.0 // indirect
55
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
56
github.com/aymerick/douceur v0.2.0 // indirect
57
github.com/beorn7/perks v1.0.1 // indirect
58
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
59
github.com/casbin/govaluate v1.3.0 // indirect
60
+
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
61
github.com/cespare/xxhash/v2 v2.3.0 // indirect
62
+
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect
63
github.com/containerd/errdefs v1.0.0 // indirect
64
github.com/containerd/errdefs/pkg v0.3.0 // indirect
65
github.com/containerd/log v0.1.0 // indirect
···
72
github.com/docker/go-units v0.5.0 // indirect
73
github.com/emirpasic/gods v1.18.1 // indirect
74
github.com/felixge/httpsnoop v1.0.4 // indirect
75
+
github.com/fsnotify/fsnotify v1.6.0 // indirect
76
github.com/go-enry/go-oniguruma v1.2.1 // indirect
77
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
78
github.com/go-git/go-billy/v5 v5.6.2 // indirect
79
+
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
80
+
github.com/go-logr/logr v1.4.3 // indirect
81
github.com/go-logr/stdr v1.2.2 // indirect
82
github.com/go-redis/cache/v9 v9.0.0 // indirect
83
+
github.com/go-test/deep v1.1.1 // indirect
84
github.com/goccy/go-json v0.10.5 // indirect
85
github.com/gogo/protobuf v1.3.2 // indirect
86
+
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
87
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
88
+
github.com/golang/mock v1.6.0 // indirect
89
+
github.com/google/go-querystring v1.1.0 // indirect
90
github.com/gorilla/css v1.0.1 // indirect
91
github.com/gorilla/securecookie v1.1.2 // indirect
92
+
github.com/hashicorp/errwrap v1.1.0 // indirect
93
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
94
+
github.com/hashicorp/go-multierror v1.1.1 // indirect
95
+
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
96
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
97
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
98
+
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
99
github.com/hashicorp/golang-lru v1.0.2 // indirect
100
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
101
+
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
102
+
github.com/hexops/gotextdiff v1.0.3 // indirect
103
github.com/ipfs/bbloom v0.0.4 // indirect
104
+
github.com/ipfs/boxo v0.33.0 // indirect
105
+
github.com/ipfs/go-block-format v0.2.2 // indirect
106
github.com/ipfs/go-datastore v0.8.2 // indirect
107
github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
108
github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect
109
+
github.com/ipfs/go-ipld-cbor v0.2.1 // indirect
110
+
github.com/ipfs/go-ipld-format v0.6.2 // indirect
111
github.com/ipfs/go-log v1.0.5 // indirect
112
github.com/ipfs/go-log/v2 v2.6.0 // indirect
113
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
114
github.com/kevinburke/ssh_config v1.2.0 // indirect
115
github.com/klauspost/compress v1.18.0 // indirect
116
+
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
117
+
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
118
github.com/lestrrat-go/httpcc v1.0.1 // indirect
119
github.com/lestrrat-go/httprc v1.0.6 // indirect
120
github.com/lestrrat-go/iter v1.0.2 // indirect
121
github.com/lestrrat-go/option v1.0.1 // indirect
122
github.com/mattn/go-isatty v0.0.20 // indirect
123
github.com/minio/sha256-simd v1.0.1 // indirect
124
+
github.com/mitchellh/mapstructure v1.5.0 // indirect
125
github.com/moby/docker-image-spec v1.3.1 // indirect
126
github.com/moby/sys/atomicwriter v0.1.0 // indirect
127
github.com/moby/term v0.5.2 // indirect
···
133
github.com/multiformats/go-multihash v0.2.3 // indirect
134
github.com/multiformats/go-varint v0.0.7 // indirect
135
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
136
+
github.com/onsi/gomega v1.37.0 // indirect
137
github.com/opencontainers/go-digest v1.0.0 // indirect
138
github.com/opencontainers/image-spec v1.1.1 // indirect
139
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
140
github.com/pjbgf/sha1cd v0.3.2 // indirect
141
github.com/pkg/errors v0.9.1 // indirect
142
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
143
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
144
github.com/prometheus/client_golang v1.22.0 // indirect
145
github.com/prometheus/client_model v0.6.2 // indirect
146
+
github.com/prometheus/common v0.64.0 // indirect
147
github.com/prometheus/procfs v0.16.1 // indirect
148
+
github.com/ryanuber/go-glob v1.0.0 // indirect
149
github.com/segmentio/asm v1.2.0 // indirect
150
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
151
github.com/spaolacci/murmur3 v1.1.0 // indirect
···
155
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
156
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
157
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
158
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
159
+
go.opentelemetry.io/otel v1.37.0 // indirect
160
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
161
+
go.opentelemetry.io/otel/metric v1.37.0 // indirect
162
+
go.opentelemetry.io/otel/trace v1.37.0 // indirect
163
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
164
go.uber.org/atomic v1.11.0 // indirect
165
go.uber.org/multierr v1.11.0 // indirect
166
go.uber.org/zap v1.27.0 // indirect
167
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
168
+
golang.org/x/sys v0.34.0 // indirect
169
+
golang.org/x/text v0.27.0 // indirect
170
+
golang.org/x/time v0.12.0 // indirect
171
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
172
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
173
+
google.golang.org/grpc v1.73.0 // indirect
174
google.golang.org/protobuf v1.36.6 // indirect
175
gopkg.in/fsnotify.v1 v1.4.7 // indirect
176
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+129
-87
go.sum
+129
-87
go.sum
···
7
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
8
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
9
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
10
-
github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs=
11
-
github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
12
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
13
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
14
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
···
23
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
24
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
25
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
26
-
github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e h1:DVD+HxQsDCVJtAkjfIKZVaBNc3kayHaU+A2TJZkFdp4=
27
-
github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng=
28
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
29
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
30
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
51
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
52
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
53
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
54
-
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
55
-
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
56
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
57
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
58
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
···
91
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
92
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
93
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
94
-
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
95
-
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
96
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
97
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
98
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
99
-
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
100
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
101
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
102
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
103
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
···
114
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
115
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY=
116
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM=
117
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
118
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
119
-
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
120
-
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
121
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
122
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
123
github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0=
124
github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI=
125
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
126
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
127
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
128
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
129
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
130
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
131
-
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
132
-
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
133
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
134
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
135
-
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
136
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
137
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
138
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
139
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
···
146
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
147
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
148
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
149
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
150
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
151
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
152
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
153
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
154
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
155
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
156
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
166
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
167
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
168
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
169
-
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
170
-
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
171
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
172
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
173
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
174
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
175
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
176
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
177
-
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
178
-
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
179
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
180
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
181
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
182
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
183
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
184
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
185
github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw=
···
189
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
190
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
191
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
192
-
github.com/ipfs/boxo v0.30.0 h1:7afsoxPGGqfoH7Dum/wOTGUB9M5fb8HyKPMlLfBvIEQ=
193
-
github.com/ipfs/boxo v0.30.0/go.mod h1:BPqgGGyHB9rZZcPSzah2Dc9C+5Or3U1aQe7EH1H7370=
194
-
github.com/ipfs/go-block-format v0.2.1 h1:96kW71XGNNa+mZw/MTzJrCpMhBWCrd9kBLoKm9Iip/Q=
195
-
github.com/ipfs/go-block-format v0.2.1/go.mod h1:frtvXHMQhM6zn7HvEQu+Qz5wSTj+04oEH/I+NjDgEjk=
196
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
197
github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
198
github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U=
···
205
github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo=
206
github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0=
207
github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs=
208
-
github.com/ipfs/go-ipld-cbor v0.2.0 h1:VHIW3HVIjcMd8m4ZLZbrYpwjzqlVUfjLM7oK4T5/YF0=
209
-
github.com/ipfs/go-ipld-cbor v0.2.0/go.mod h1:Cp8T7w1NKcu4AQJLqK0tWpd1nkgTxEVB5C6kVpLW6/0=
210
-
github.com/ipfs/go-ipld-format v0.6.1 h1:lQLmBM/HHbrXvjIkrydRXkn+gc0DE5xO5fqelsCKYOQ=
211
-
github.com/ipfs/go-ipld-format v0.6.1/go.mod h1:8TOH1Hj+LFyqM2PjSqI2/ZnyO0KlfhHbJLkbxFa61hs=
212
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
213
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
214
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
···
216
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
217
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
218
github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY=
219
-
github.com/ipfs/go-test v0.2.1 h1:/D/a8xZ2JzkYqcVcV/7HYlCnc7bv/pKHQiX5TdClkPE=
220
-
github.com/ipfs/go-test v0.2.1/go.mod h1:dzu+KB9cmWjuJnXFDYJwC25T3j1GcN57byN+ixmK39M=
221
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
222
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
223
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
···
229
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
230
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
231
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
232
-
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
233
-
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
234
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
235
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
236
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
···
239
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
240
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
241
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
242
-
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
243
-
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
244
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
245
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
246
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
···
251
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
252
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
253
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
254
-
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
255
-
github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg=
256
-
github.com/libp2p/go-libp2p v0.41.1 h1:8ecNQVT5ev/jqALTvisSJeVNvXYJyK4NhQx1nNRXQZE=
257
-
github.com/libp2p/go-libp2p v0.41.1/go.mod h1:DcGTovJzQl/I7HMrby5ZRjeD0kQkGiy+9w6aEkSZpRI=
258
-
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
259
-
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
260
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
261
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
262
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
···
265
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
266
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
267
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
268
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
269
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
270
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
···
281
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
282
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
283
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
284
-
github.com/multiformats/go-multiaddr v0.15.0 h1:zB/HeaI/apcZiTDwhY5YqMvNVl/oQYvs3XySU+qeAVo=
285
-
github.com/multiformats/go-multiaddr v0.15.0/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0=
286
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
287
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
288
-
github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg=
289
-
github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k=
290
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
291
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
292
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
···
318
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
319
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
320
github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM=
321
-
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
322
-
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
323
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
324
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
325
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
326
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
327
-
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
328
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
329
github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU=
330
github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
331
github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo=
···
346
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
347
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
348
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
349
-
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
350
-
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
351
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
352
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
353
github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA=
354
-
github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0=
355
-
github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
356
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
357
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
358
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
···
360
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
361
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
362
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
363
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
364
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
365
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
···
404
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
405
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
406
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
407
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
408
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
409
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
···
413
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
414
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
415
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
416
-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
417
-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
418
-
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
419
-
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
420
-
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk=
421
-
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME=
422
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU=
423
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4=
424
-
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
425
-
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
426
-
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
427
-
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
428
-
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
429
-
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
430
-
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
431
-
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
432
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
433
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
434
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
···
451
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
452
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
453
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
454
-
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
455
-
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
456
-
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
457
-
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
458
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
459
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
460
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
461
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
462
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
463
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
464
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
465
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
466
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
467
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
468
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
···
471
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
472
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
473
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
474
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
475
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
476
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
···
480
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
481
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
482
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
483
-
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
484
-
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
485
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
486
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
487
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
489
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
490
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
491
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
492
-
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
493
-
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
494
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
495
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
496
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
502
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
503
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
504
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
505
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
506
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
507
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
508
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
510
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
511
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
512
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
513
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
514
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
515
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
516
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
517
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
518
-
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
519
-
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
520
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
521
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
522
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
523
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
524
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
525
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
526
-
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
527
-
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
528
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
529
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
530
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
···
532
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
533
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
534
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
535
-
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
536
-
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
537
-
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
538
-
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
539
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
540
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
541
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
547
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
548
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
549
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
550
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
551
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
552
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
553
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
554
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
555
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
556
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
557
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
558
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
559
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
560
-
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
561
-
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
562
-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
563
-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
564
-
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
565
-
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
566
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
567
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
568
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
···
599
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
600
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
601
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
602
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 h1:ZQNKE1HKWjfQBizxDb2XLhrJcgZqE0MLFIU1iggaF90=
603
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421/go.mod h1:Wad0H70uyyY4qZryU/1Ic+QZyw41YxN5QfzNAEBaXkQ=
604
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
605
tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
···
7
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
8
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
9
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
10
+
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
11
+
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
12
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
13
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
14
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
···
23
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
24
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
25
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
26
+
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ=
27
+
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
28
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
29
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
30
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
51
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
52
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
53
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
54
+
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4=
55
+
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
56
+
github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM=
57
+
github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU=
58
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
59
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
60
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
···
93
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
94
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
95
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
96
+
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
97
+
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
98
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
99
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
100
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
101
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
102
+
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
103
+
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
104
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
105
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
106
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
···
117
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
118
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY=
119
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM=
120
+
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
121
+
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
122
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
123
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
124
+
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
125
+
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
126
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
127
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
128
github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0=
129
github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI=
130
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
131
+
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
132
+
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
133
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
134
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
135
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
136
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
137
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
138
+
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
139
+
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
140
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
141
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
142
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
143
+
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
144
+
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
145
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
146
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
147
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
···
154
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
155
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
156
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
157
+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
158
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
159
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
160
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
161
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
162
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
163
+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
164
+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
165
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
166
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
167
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
177
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
178
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
179
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
180
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
181
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
182
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
183
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
184
+
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
185
+
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
186
+
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
187
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
188
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
189
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
190
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
191
+
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
192
+
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
193
+
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
194
+
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
195
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
196
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
197
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
198
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
199
+
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
200
+
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
201
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
202
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
203
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
204
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
205
+
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
206
+
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
207
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
208
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
209
github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw=
···
213
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
214
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
215
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
216
+
github.com/ipfs/boxo v0.33.0 h1:9ow3chwkDzMj0Deq4AWRUEI7WnIIV7SZhPTzzG2mmfw=
217
+
github.com/ipfs/boxo v0.33.0/go.mod h1:3IPh7YFcCIcKp6o02mCHovrPntoT5Pctj/7j4syh/RM=
218
+
github.com/ipfs/go-block-format v0.2.2 h1:uecCTgRwDIXyZPgYspaLXoMiMmxQpSx2aq34eNc4YvQ=
219
+
github.com/ipfs/go-block-format v0.2.2/go.mod h1:vmuefuWU6b+9kIU0vZJgpiJt1yicQz9baHXE8qR+KB8=
220
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
221
github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
222
github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U=
···
229
github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo=
230
github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0=
231
github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs=
232
+
github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E=
233
+
github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A=
234
+
github.com/ipfs/go-ipld-format v0.6.2 h1:bPZQ+A05ol0b3lsJSl0bLvwbuQ+HQbSsdGTy4xtYUkU=
235
+
github.com/ipfs/go-ipld-format v0.6.2/go.mod h1:nni2xFdHKx5lxvXJ6brt/pndtGxKAE+FPR1rg4jTkyk=
236
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
237
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
238
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
···
240
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
241
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
242
github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY=
243
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
244
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
245
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
···
251
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
252
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
253
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
254
+
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
255
+
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
256
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
257
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
258
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
···
261
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
262
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
263
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
264
+
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
265
+
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
266
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
267
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
268
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
···
273
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
274
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
275
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
276
+
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
277
+
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
278
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
279
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
280
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
···
283
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
284
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
285
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
286
+
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
287
+
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
288
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
289
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
290
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
···
301
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
302
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
303
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
304
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
305
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
306
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
307
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
308
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
···
334
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
335
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
336
github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM=
337
+
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
338
+
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
339
+
github.com/openbao/openbao/api/v2 v2.3.0 h1:61FO3ILtpKoxbD9kTWeGaCq8pz1sdt4dv2cmTXsiaAc=
340
+
github.com/openbao/openbao/api/v2 v2.3.0/go.mod h1:T47WKHb7DqHa3Ms3xicQtl5EiPE+U8diKjb9888okWs=
341
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
342
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
343
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
344
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
345
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
346
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
347
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
348
github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU=
349
github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
350
github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo=
···
365
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
366
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
367
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
368
+
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
369
+
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
370
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
371
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
372
github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA=
373
+
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
374
+
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
375
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
376
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
377
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
···
379
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
380
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
381
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
382
+
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
383
+
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
384
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
385
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
386
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
···
425
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
426
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
427
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
428
+
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
429
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
430
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
431
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
···
435
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
436
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
437
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
438
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
439
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
440
+
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
441
+
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
442
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
443
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
444
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU=
445
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4=
446
+
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
447
+
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
448
+
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
449
+
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
450
+
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
451
+
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
452
+
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
453
+
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
454
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
455
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
456
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
···
473
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
474
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
475
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
476
+
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
477
+
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
478
+
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
479
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
480
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
481
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
482
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
483
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
484
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
485
+
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
486
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
487
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
488
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
489
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
490
+
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
491
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
492
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
493
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
···
496
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
497
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
498
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
499
+
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
500
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
501
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
502
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
···
506
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
507
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
508
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
509
+
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
510
+
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
511
+
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
512
+
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
513
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
514
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
515
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
517
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
518
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
519
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
520
+
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
521
+
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
522
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
523
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
524
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
530
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
531
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
532
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
533
+
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
534
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
535
+
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
536
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
537
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
538
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
540
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
541
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
542
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
543
+
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
544
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
545
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
546
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
547
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
548
+
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
549
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
550
+
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
551
+
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
552
+
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
553
+
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
554
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
555
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
556
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
557
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
558
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
559
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
560
+
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
561
+
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
562
+
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
563
+
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
564
+
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
565
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
566
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
567
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
···
569
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
570
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
571
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
572
+
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
573
+
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
574
+
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
575
+
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
576
+
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
577
+
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
578
+
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
579
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
580
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
581
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
587
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
588
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
589
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
590
+
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
591
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
592
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
593
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
594
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
595
+
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
596
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
597
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
598
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
599
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
600
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
601
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
602
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
603
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
604
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
605
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
606
+
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
607
+
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
608
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
609
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
610
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
···
641
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
642
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
643
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
644
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU=
645
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg=
646
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
647
tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+20
-4
guard/guard.go
+20
-4
guard/guard.go
···
2
3
import (
4
"context"
5
"fmt"
6
"log/slog"
7
"net/http"
8
"net/url"
···
13
"github.com/bluesky-social/indigo/atproto/identity"
14
securejoin "github.com/cyphar/filepath-securejoin"
15
"github.com/urfave/cli/v3"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
17
"tangled.sh/tangled.sh/core/log"
18
)
19
···
43
Usage: "internal API endpoint",
44
Value: "http://localhost:5444",
45
},
46
},
47
}
48
}
···
54
gitDir := cmd.String("git-dir")
55
logPath := cmd.String("log-path")
56
endpoint := cmd.String("internal-api")
57
58
logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
59
if err != nil {
···
149
"fullPath", fullPath,
150
"client", clientIP)
151
152
-
if gitCommand == "git-upload-pack" {
153
-
fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!")
154
} else {
155
-
fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!")
156
}
157
158
gitCmd := exec.Command(gitCommand, fullPath)
159
gitCmd.Stdout = os.Stdout
···
2
3
import (
4
"context"
5
+
"errors"
6
"fmt"
7
+
"io"
8
"log/slog"
9
"net/http"
10
"net/url"
···
15
"github.com/bluesky-social/indigo/atproto/identity"
16
securejoin "github.com/cyphar/filepath-securejoin"
17
"github.com/urfave/cli/v3"
18
+
"tangled.sh/tangled.sh/core/idresolver"
19
"tangled.sh/tangled.sh/core/log"
20
)
21
···
45
Usage: "internal API endpoint",
46
Value: "http://localhost:5444",
47
},
48
+
&cli.StringFlag{
49
+
Name: "motd-file",
50
+
Usage: "path to message of the day file",
51
+
Value: "/home/git/motd",
52
+
},
53
},
54
}
55
}
···
61
gitDir := cmd.String("git-dir")
62
logPath := cmd.String("log-path")
63
endpoint := cmd.String("internal-api")
64
+
motdFile := cmd.String("motd-file")
65
66
logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
67
if err != nil {
···
157
"fullPath", fullPath,
158
"client", clientIP)
159
160
+
var motdReader io.Reader
161
+
if reader, err := os.Open(motdFile); err != nil {
162
+
if !errors.Is(err, os.ErrNotExist) {
163
+
l.Error("failed to read motd file", "error", err)
164
+
}
165
+
motdReader = strings.NewReader("Welcome to this knot!\n")
166
} else {
167
+
motdReader = reader
168
+
}
169
+
if gitCommand == "git-upload-pack" {
170
+
io.WriteString(os.Stderr, "\x02")
171
}
172
+
io.Copy(os.Stderr, motdReader)
173
174
gitCmd := exec.Command(gitCommand, fullPath)
175
gitCmd.Stdout = os.Stdout
+24
hook/hook.go
+24
hook/hook.go
···
3
import (
4
"bufio"
5
"context"
6
"fmt"
7
"net/http"
8
"os"
···
10
11
"github.com/urfave/cli/v3"
12
)
13
14
// The hook command is nested like so:
15
//
···
36
Usage: "endpoint for the internal API",
37
Value: "http://localhost:5444",
38
},
39
},
40
Commands: []*cli.Command{
41
{
···
52
userDid := cmd.String("user-did")
53
userHandle := cmd.String("user-handle")
54
endpoint := cmd.String("internal-api")
55
56
payloadReader := bufio.NewReader(os.Stdin)
57
payload, _ := payloadReader.ReadString('\n')
···
67
req.Header.Set("X-Git-Dir", gitDir)
68
req.Header.Set("X-Git-User-Did", userDid)
69
req.Header.Set("X-Git-User-Handle", userHandle)
70
71
resp, err := client.Do(req)
72
if err != nil {
···
76
77
if resp.StatusCode != http.StatusOK {
78
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
79
}
80
81
return nil
···
3
import (
4
"bufio"
5
"context"
6
+
"encoding/json"
7
"fmt"
8
"net/http"
9
"os"
···
11
12
"github.com/urfave/cli/v3"
13
)
14
+
15
+
type HookResponse struct {
16
+
Messages []string `json:"messages"`
17
+
}
18
19
// The hook command is nested like so:
20
//
···
41
Usage: "endpoint for the internal API",
42
Value: "http://localhost:5444",
43
},
44
+
&cli.StringSliceFlag{
45
+
Name: "push-option",
46
+
Usage: "any push option from git",
47
+
},
48
},
49
Commands: []*cli.Command{
50
{
···
61
userDid := cmd.String("user-did")
62
userHandle := cmd.String("user-handle")
63
endpoint := cmd.String("internal-api")
64
+
pushOptions := cmd.StringSlice("push-option")
65
66
payloadReader := bufio.NewReader(os.Stdin)
67
payload, _ := payloadReader.ReadString('\n')
···
77
req.Header.Set("X-Git-Dir", gitDir)
78
req.Header.Set("X-Git-User-Did", userDid)
79
req.Header.Set("X-Git-User-Handle", userHandle)
80
+
if pushOptions != nil {
81
+
for _, option := range pushOptions {
82
+
req.Header.Add("X-Git-Push-Option", option)
83
+
}
84
+
}
85
86
resp, err := client.Do(req)
87
if err != nil {
···
91
92
if resp.StatusCode != http.StatusOK {
93
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
94
+
}
95
+
96
+
var data HookResponse
97
+
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
98
+
return fmt.Errorf("failed to decode response: %w", err)
99
+
}
100
+
101
+
for _, message := range data.Messages {
102
+
fmt.Println(message)
103
}
104
105
return nil
+6
-1
hook/setup.go
+6
-1
hook/setup.go
···
133
134
hookContent := fmt.Sprintf(`#!/usr/bin/env bash
135
# AUTO GENERATED BY KNOT, DO NOT MODIFY
136
-
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" post-recieve
137
`, executablePath, config.internalApi)
138
139
return os.WriteFile(hookPath, []byte(hookContent), 0755)
···
133
134
hookContent := fmt.Sprintf(`#!/usr/bin/env bash
135
# AUTO GENERATED BY KNOT, DO NOT MODIFY
136
+
push_options=()
137
+
for ((i=0; i<GIT_PUSH_OPTION_COUNT; i++)); do
138
+
option_var="GIT_PUSH_OPTION_$i"
139
+
push_options+=(-push-option "${!option_var}")
140
+
done
141
+
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve
142
`, executablePath, config.internalApi)
143
144
return os.WriteFile(hookPath, []byte(hookContent), 0755)
+116
idresolver/resolver.go
+116
idresolver/resolver.go
···
···
1
+
package idresolver
2
+
3
+
import (
4
+
"context"
5
+
"net"
6
+
"net/http"
7
+
"sync"
8
+
"time"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/identity"
11
+
"github.com/bluesky-social/indigo/atproto/identity/redisdir"
12
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"github.com/carlmjohnson/versioninfo"
14
+
)
15
+
16
+
type Resolver struct {
17
+
directory identity.Directory
18
+
}
19
+
20
+
func BaseDirectory() identity.Directory {
21
+
base := identity.BaseDirectory{
22
+
PLCURL: identity.DefaultPLCURL,
23
+
HTTPClient: http.Client{
24
+
Timeout: time.Second * 10,
25
+
Transport: &http.Transport{
26
+
// would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad.
27
+
IdleConnTimeout: time.Millisecond * 1000,
28
+
MaxIdleConns: 100,
29
+
},
30
+
},
31
+
Resolver: net.Resolver{
32
+
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
33
+
d := net.Dialer{Timeout: time.Second * 3}
34
+
return d.DialContext(ctx, network, address)
35
+
},
36
+
},
37
+
TryAuthoritativeDNS: true,
38
+
// primary Bluesky PDS instance only supports HTTP resolution method
39
+
SkipDNSDomainSuffixes: []string{".bsky.social"},
40
+
UserAgent: "indigo-identity/" + versioninfo.Short(),
41
+
}
42
+
return &base
43
+
}
44
+
45
+
func RedisDirectory(url string) (identity.Directory, error) {
46
+
hitTTL := time.Hour * 24
47
+
errTTL := time.Second * 30
48
+
invalidHandleTTL := time.Minute * 5
49
+
return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
50
+
}
51
+
52
+
func DefaultResolver() *Resolver {
53
+
return &Resolver{
54
+
directory: identity.DefaultDirectory(),
55
+
}
56
+
}
57
+
58
+
func RedisResolver(redisUrl string) (*Resolver, error) {
59
+
directory, err := RedisDirectory(redisUrl)
60
+
if err != nil {
61
+
return nil, err
62
+
}
63
+
return &Resolver{
64
+
directory: directory,
65
+
}, nil
66
+
}
67
+
68
+
func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) {
69
+
id, err := syntax.ParseAtIdentifier(arg)
70
+
if err != nil {
71
+
return nil, err
72
+
}
73
+
74
+
return r.directory.Lookup(ctx, *id)
75
+
}
76
+
77
+
func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity {
78
+
results := make([]*identity.Identity, len(idents))
79
+
var wg sync.WaitGroup
80
+
81
+
done := make(chan struct{})
82
+
defer close(done)
83
+
84
+
for idx, ident := range idents {
85
+
wg.Add(1)
86
+
go func(index int, id string) {
87
+
defer wg.Done()
88
+
89
+
select {
90
+
case <-ctx.Done():
91
+
results[index] = nil
92
+
case <-done:
93
+
results[index] = nil
94
+
default:
95
+
identity, _ := r.ResolveIdent(ctx, id)
96
+
results[index] = identity
97
+
}
98
+
}(idx, ident)
99
+
}
100
+
101
+
wg.Wait()
102
+
return results
103
+
}
104
+
105
+
func (r *Resolver) InvalidateIdent(ctx context.Context, arg string) error {
106
+
id, err := syntax.ParseAtIdentifier(arg)
107
+
if err != nil {
108
+
return err
109
+
}
110
+
111
+
return r.directory.Purge(ctx, *id)
112
+
}
113
+
114
+
func (r *Resolver) Directory() identity.Directory {
115
+
return r.directory
116
+
}
+1
-2
input.css
+1
-2
input.css
+13
jetstream/jetstream.go
+13
jetstream/jetstream.go
···
52
j.mu.Unlock()
53
}
54
55
+
func (j *JetstreamClient) RemoveDid(did string) {
56
+
if did == "" {
57
+
return
58
+
}
59
+
60
+
if j.logDids {
61
+
j.l.Info("removing did from in-memory filter", "did", did)
62
+
}
63
+
j.mu.Lock()
64
+
delete(j.wantedDids, did)
65
+
j.mu.Unlock()
66
+
}
67
+
68
type processor func(context.Context, *models.Event) error
69
70
func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
+6
knotserver/config/config.go
+6
knotserver/config/config.go
···
2
3
import (
4
"context"
5
+
"fmt"
6
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
"github.com/sethvargo/go-envconfig"
9
)
10
···
25
26
// This disables signature verification so use with caution.
27
Dev bool `env:"DEV, default=false"`
28
+
}
29
+
30
+
func (s Server) Did() syntax.DID {
31
+
return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname))
32
}
33
34
type Config struct {
-8
knotserver/file.go
-8
knotserver/file.go
···
10
"tangled.sh/tangled.sh/core/types"
11
)
12
13
-
func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) {
14
-
data["files"] = files
15
-
16
-
writeJSON(w, data)
17
-
return
18
-
}
19
-
20
func countLines(r io.Reader) (int, error) {
21
buf := make([]byte, 32*1024)
22
bufLen := 0
···
52
53
resp.Lines = lc
54
writeJSON(w, resp)
55
-
return
56
}
+37
-18
knotserver/handler.go
+37
-18
knotserver/handler.go
···
8
"runtime/debug"
9
10
"github.com/go-chi/chi/v5"
11
"tangled.sh/tangled.sh/core/jetstream"
12
"tangled.sh/tangled.sh/core/knotserver/config"
13
"tangled.sh/tangled.sh/core/knotserver/db"
14
"tangled.sh/tangled.sh/core/notifier"
15
"tangled.sh/tangled.sh/core/rbac"
16
-
)
17
-
18
-
const (
19
-
ThisServer = "thisserver" // resource identifier for rbac enforcement
20
)
21
22
type Handle struct {
23
-
c *config.Config
24
-
db *db.DB
25
-
jc *jetstream.JetstreamClient
26
-
e *rbac.Enforcer
27
-
l *slog.Logger
28
-
n *notifier.Notifier
29
30
// init is a channel that is closed when the knot has been initailized
31
// i.e. when the first user (knot owner) has been added.
···
37
r := chi.NewRouter()
38
39
h := Handle{
40
-
c: c,
41
-
db: db,
42
-
e: e,
43
-
l: l,
44
-
jc: jc,
45
-
n: n,
46
-
init: make(chan struct{}),
47
}
48
49
-
err := e.AddKnot(ThisServer)
50
if err != nil {
51
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
52
}
···
131
})
132
})
133
134
// Create a new repository.
135
r.Route("/repo", func(r chi.Router) {
136
r.Use(h.VerifySignature)
···
161
r.Get("/keys", h.Keys)
162
163
return r, nil
164
}
165
166
// version is set during build time.
···
8
"runtime/debug"
9
10
"github.com/go-chi/chi/v5"
11
+
"tangled.sh/tangled.sh/core/idresolver"
12
"tangled.sh/tangled.sh/core/jetstream"
13
"tangled.sh/tangled.sh/core/knotserver/config"
14
"tangled.sh/tangled.sh/core/knotserver/db"
15
+
"tangled.sh/tangled.sh/core/knotserver/xrpc"
16
+
tlog "tangled.sh/tangled.sh/core/log"
17
"tangled.sh/tangled.sh/core/notifier"
18
"tangled.sh/tangled.sh/core/rbac"
19
)
20
21
type Handle struct {
22
+
c *config.Config
23
+
db *db.DB
24
+
jc *jetstream.JetstreamClient
25
+
e *rbac.Enforcer
26
+
l *slog.Logger
27
+
n *notifier.Notifier
28
+
resolver *idresolver.Resolver
29
30
// init is a channel that is closed when the knot has been initailized
31
// i.e. when the first user (knot owner) has been added.
···
37
r := chi.NewRouter()
38
39
h := Handle{
40
+
c: c,
41
+
db: db,
42
+
e: e,
43
+
l: l,
44
+
jc: jc,
45
+
n: n,
46
+
resolver: idresolver.DefaultResolver(),
47
+
init: make(chan struct{}),
48
}
49
50
+
err := e.AddKnot(rbac.ThisServer)
51
if err != nil {
52
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
53
}
···
132
})
133
})
134
135
+
// xrpc apis
136
+
r.Mount("/xrpc", h.XrpcRouter())
137
+
138
// Create a new repository.
139
r.Route("/repo", func(r chi.Router) {
140
r.Use(h.VerifySignature)
···
165
r.Get("/keys", h.Keys)
166
167
return r, nil
168
+
}
169
+
170
+
func (h *Handle) XrpcRouter() http.Handler {
171
+
logger := tlog.New("knots")
172
+
173
+
xrpc := &xrpc.Xrpc{
174
+
Config: h.c,
175
+
Db: h.db,
176
+
Ingester: h.jc,
177
+
Enforcer: h.e,
178
+
Logger: logger,
179
+
Notifier: h.n,
180
+
Resolver: h.resolver,
181
+
}
182
+
return xrpc.Router()
183
}
184
185
// version is set during build time.
+65
-4
knotserver/ingester.go
+65
-4
knotserver/ingester.go
···
17
"github.com/bluesky-social/jetstream/pkg/models"
18
securejoin "github.com/cyphar/filepath-securejoin"
19
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
21
"tangled.sh/tangled.sh/core/knotserver/db"
22
"tangled.sh/tangled.sh/core/knotserver/git"
23
"tangled.sh/tangled.sh/core/log"
24
"tangled.sh/tangled.sh/core/workflow"
25
)
26
···
46
return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname)
47
}
48
49
-
ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite")
50
if err != nil || !ok {
51
l.Error("failed to add member", "did", did)
52
return fmt.Errorf("failed to enforce permissions: %w", err)
53
}
54
55
-
if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil {
56
l.Error("failed to add member", "error", err)
57
return fmt.Errorf("failed to add member: %w", err)
58
}
···
212
return h.db.InsertEvent(event, h.n)
213
}
214
215
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
216
l := log.FromContext(ctx)
217
···
265
defer func() {
266
eventTime := event.TimeUS
267
lastTimeUs := eventTime + 1
268
-
fmt.Println("lastTimeUs", lastTimeUs)
269
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
270
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
271
}
···
291
if err := h.processKnotMember(ctx, did, record); err != nil {
292
return fmt.Errorf("failed to process knot member: %w", err)
293
}
294
case tangled.RepoPullNSID:
295
var record tangled.RepoPull
296
if err := json.Unmarshal(raw, &record); err != nil {
···
299
if err := h.processPull(ctx, did, record); err != nil {
300
return fmt.Errorf("failed to process knot member: %w", err)
301
}
302
}
303
304
return err
···
17
"github.com/bluesky-social/jetstream/pkg/models"
18
securejoin "github.com/cyphar/filepath-securejoin"
19
"tangled.sh/tangled.sh/core/api/tangled"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
"tangled.sh/tangled.sh/core/knotserver/db"
22
"tangled.sh/tangled.sh/core/knotserver/git"
23
"tangled.sh/tangled.sh/core/log"
24
+
"tangled.sh/tangled.sh/core/rbac"
25
"tangled.sh/tangled.sh/core/workflow"
26
)
27
···
47
return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname)
48
}
49
50
+
ok, err := h.e.E.Enforce(did, rbac.ThisServer, rbac.ThisServer, "server:invite")
51
if err != nil || !ok {
52
l.Error("failed to add member", "did", did)
53
return fmt.Errorf("failed to enforce permissions: %w", err)
54
}
55
56
+
if err := h.e.AddKnotMember(rbac.ThisServer, record.Subject); err != nil {
57
l.Error("failed to add member", "error", err)
58
return fmt.Errorf("failed to add member: %w", err)
59
}
···
213
return h.db.InsertEvent(event, h.n)
214
}
215
216
+
// duplicated from add collaborator
217
+
func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error {
218
+
repoAt, err := syntax.ParseATURI(record.Repo)
219
+
if err != nil {
220
+
return err
221
+
}
222
+
223
+
resolver := idresolver.DefaultResolver()
224
+
225
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
226
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
227
+
return err
228
+
}
229
+
230
+
// TODO: fix this for good, we need to fetch the record here unfortunately
231
+
// resolve this aturi to extract the repo record
232
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
233
+
if err != nil || owner.Handle.IsInvalidHandle() {
234
+
return fmt.Errorf("failed to resolve handle: %w", err)
235
+
}
236
+
237
+
xrpcc := xrpc.Client{
238
+
Host: owner.PDSEndpoint(),
239
+
}
240
+
241
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
242
+
if err != nil {
243
+
return err
244
+
}
245
+
246
+
repo := resp.Value.Val.(*tangled.Repo)
247
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
248
+
249
+
// check perms for this user
250
+
if ok, err := h.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil {
251
+
return fmt.Errorf("insufficient permissions: %w", err)
252
+
}
253
+
254
+
if err := h.db.AddDid(subjectId.DID.String()); err != nil {
255
+
return err
256
+
}
257
+
h.jc.AddDid(subjectId.DID.String())
258
+
259
+
if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil {
260
+
return err
261
+
}
262
+
263
+
return h.fetchAndAddKeys(ctx, subjectId.DID.String())
264
+
}
265
+
266
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
267
l := log.FromContext(ctx)
268
···
316
defer func() {
317
eventTime := event.TimeUS
318
lastTimeUs := eventTime + 1
319
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
320
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
321
}
···
341
if err := h.processKnotMember(ctx, did, record); err != nil {
342
return fmt.Errorf("failed to process knot member: %w", err)
343
}
344
+
345
case tangled.RepoPullNSID:
346
var record tangled.RepoPull
347
if err := json.Unmarshal(raw, &record); err != nil {
···
350
if err := h.processPull(ctx, did, record); err != nil {
351
return fmt.Errorf("failed to process knot member: %w", err)
352
}
353
+
354
+
case tangled.RepoCollaboratorNSID:
355
+
var record tangled.RepoCollaborator
356
+
if err := json.Unmarshal(raw, &record); err != nil {
357
+
return fmt.Errorf("failed to unmarshal record: %w", err)
358
+
}
359
+
if err := h.processCollaborator(ctx, did, record); err != nil {
360
+
return fmt.Errorf("failed to process knot member: %w", err)
361
+
}
362
+
363
}
364
365
return err
+62
-5
knotserver/internal.go
+62
-5
knotserver/internal.go
···
13
"github.com/go-chi/chi/v5"
14
"github.com/go-chi/chi/v5/middleware"
15
"tangled.sh/tangled.sh/core/api/tangled"
16
"tangled.sh/tangled.sh/core/knotserver/config"
17
"tangled.sh/tangled.sh/core/knotserver/db"
18
"tangled.sh/tangled.sh/core/knotserver/git"
···
38
return
39
}
40
41
-
ok, err := h.e.IsPushAllowed(user, ThisServer, repo)
42
if err != nil || !ok {
43
w.WriteHeader(http.StatusForbidden)
44
return
···
64
return
65
}
66
67
func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
68
l := h.l.With("handler", "PostReceiveHook")
69
···
90
// non-fatal
91
}
92
93
for _, line := range lines {
94
err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
95
if err != nil {
···
97
// non-fatal
98
}
99
100
-
err = h.triggerPipeline(line, gitUserDid, repoDid, repoName)
101
if err != nil {
102
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
103
// non-fatal
104
}
105
}
106
}
107
108
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
···
148
return h.db.InsertEvent(event, h.n)
149
}
150
151
-
func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
152
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
153
if err != nil {
154
return err
···
169
return err
170
}
171
172
var pipeline workflow.Pipeline
173
for _, e := range workflowDir {
174
if !e.IsFile {
···
183
184
wf, err := workflow.FromFile(e.Name, contents)
185
if err != nil {
186
-
// TODO: log here, respond to client that is pushing
187
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
188
continue
189
}
190
···
209
},
210
}
211
212
-
// TODO: send the diagnostics back to the user here via stderr
213
cp := compiler.Compile(pipeline)
214
eventJson, err := json.Marshal(cp)
215
if err != nil {
216
return err
217
}
218
219
// do not run empty pipelines
···
13
"github.com/go-chi/chi/v5"
14
"github.com/go-chi/chi/v5/middleware"
15
"tangled.sh/tangled.sh/core/api/tangled"
16
+
"tangled.sh/tangled.sh/core/hook"
17
"tangled.sh/tangled.sh/core/knotserver/config"
18
"tangled.sh/tangled.sh/core/knotserver/db"
19
"tangled.sh/tangled.sh/core/knotserver/git"
···
39
return
40
}
41
42
+
ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
43
if err != nil || !ok {
44
w.WriteHeader(http.StatusForbidden)
45
return
···
65
return
66
}
67
68
+
type PushOptions struct {
69
+
skipCi bool
70
+
verboseCi bool
71
+
}
72
+
73
func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
74
l := h.l.With("handler", "PostReceiveHook")
75
···
96
// non-fatal
97
}
98
99
+
// extract any push options
100
+
pushOptionsRaw := r.Header.Values("X-Git-Push-Option")
101
+
pushOptions := PushOptions{}
102
+
for _, option := range pushOptionsRaw {
103
+
if option == "skip-ci" || option == "ci-skip" {
104
+
pushOptions.skipCi = true
105
+
}
106
+
if option == "verbose-ci" || option == "ci-verbose" {
107
+
pushOptions.verboseCi = true
108
+
}
109
+
}
110
+
111
+
resp := hook.HookResponse{
112
+
Messages: make([]string, 0),
113
+
}
114
+
115
for _, line := range lines {
116
err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
117
if err != nil {
···
119
// non-fatal
120
}
121
122
+
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
123
if err != nil {
124
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
125
// non-fatal
126
}
127
}
128
+
129
+
writeJSON(w, resp)
130
}
131
132
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
···
172
return h.db.InsertEvent(event, h.n)
173
}
174
175
+
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
176
+
if pushOptions.skipCi {
177
+
return nil
178
+
}
179
+
180
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
181
if err != nil {
182
return err
···
197
return err
198
}
199
200
+
pipelineParseErrors := []string{}
201
+
202
var pipeline workflow.Pipeline
203
for _, e := range workflowDir {
204
if !e.IsFile {
···
213
214
wf, err := workflow.FromFile(e.Name, contents)
215
if err != nil {
216
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
217
+
pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err))
218
continue
219
}
220
···
239
},
240
}
241
242
cp := compiler.Compile(pipeline)
243
eventJson, err := json.Marshal(cp)
244
if err != nil {
245
return err
246
+
}
247
+
248
+
if pushOptions.verboseCi {
249
+
hasDiagnostics := false
250
+
if len(pipelineParseErrors) > 0 {
251
+
hasDiagnostics = true
252
+
*clientMsgs = append(*clientMsgs, "error: failed to parse workflow(s):")
253
+
for _, error := range pipelineParseErrors {
254
+
*clientMsgs = append(*clientMsgs, error)
255
+
}
256
+
}
257
+
if len(compiler.Diagnostics.Errors) > 0 {
258
+
hasDiagnostics = true
259
+
*clientMsgs = append(*clientMsgs, "error(s) on pipeline:")
260
+
for _, error := range compiler.Diagnostics.Errors {
261
+
*clientMsgs = append(*clientMsgs, fmt.Sprintf("- %s:", error))
262
+
}
263
+
}
264
+
if len(compiler.Diagnostics.Warnings) > 0 {
265
+
hasDiagnostics = true
266
+
*clientMsgs = append(*clientMsgs, "warning(s) on pipeline:")
267
+
for _, warning := range compiler.Diagnostics.Warnings {
268
+
*clientMsgs = append(*clientMsgs, fmt.Sprintf("- at %s: %s: %s", warning.Path, warning.Type, warning.Reason))
269
+
}
270
+
}
271
+
if !hasDiagnostics {
272
+
*clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
273
+
}
274
}
275
276
// do not run empty pipelines
+17
-8
knotserver/routes.go
+17
-8
knotserver/routes.go
···
29
"tangled.sh/tangled.sh/core/knotserver/db"
30
"tangled.sh/tangled.sh/core/knotserver/git"
31
"tangled.sh/tangled.sh/core/patchutil"
32
"tangled.sh/tangled.sh/core/types"
33
)
34
···
285
mimeType = "image/svg+xml"
286
}
287
288
-
if !strings.HasPrefix(mimeType, "image/") && !strings.HasPrefix(mimeType, "video/") {
289
-
l.Error("attempted to serve non-image/video file", "mimetype", mimeType)
290
-
writeError(w, "only image and video files can be accessed directly", http.StatusForbidden)
291
return
292
}
293
···
674
}
675
676
// add perms for this user to access the repo
677
-
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
678
if err != nil {
679
l.Error("adding repo permissions", "error", err.Error())
680
writeError(w, err.Error(), http.StatusInternalServerError)
···
892
}
893
894
// add perms for this user to access the repo
895
-
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
896
if err != nil {
897
l.Error("adding repo permissions", "error", err.Error())
898
writeError(w, err.Error(), http.StatusInternalServerError)
···
1146
}
1147
h.jc.AddDid(did)
1148
1149
-
if err := h.e.AddKnotMember(ThisServer, did); err != nil {
1150
l.Error("adding member", "error", err.Error())
1151
writeError(w, err.Error(), http.StatusInternalServerError)
1152
return
···
1184
h.jc.AddDid(data.Did)
1185
1186
repoName, _ := securejoin.SecureJoin(ownerDid, repo)
1187
-
if err := h.e.AddCollaborator(data.Did, ThisServer, repoName); err != nil {
1188
l.Error("adding repo collaborator", "error", err.Error())
1189
writeError(w, err.Error(), http.StatusInternalServerError)
1190
return
···
1281
}
1282
h.jc.AddDid(data.Did)
1283
1284
-
if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil {
1285
l.Error("adding owner", "error", err.Error())
1286
writeError(w, err.Error(), http.StatusInternalServerError)
1287
return
···
29
"tangled.sh/tangled.sh/core/knotserver/db"
30
"tangled.sh/tangled.sh/core/knotserver/git"
31
"tangled.sh/tangled.sh/core/patchutil"
32
+
"tangled.sh/tangled.sh/core/rbac"
33
"tangled.sh/tangled.sh/core/types"
34
)
35
···
286
mimeType = "image/svg+xml"
287
}
288
289
+
// allow image, video, and text/plain files to be served directly
290
+
switch {
291
+
case strings.HasPrefix(mimeType, "image/"):
292
+
// allowed
293
+
case strings.HasPrefix(mimeType, "video/"):
294
+
// allowed
295
+
case strings.HasPrefix(mimeType, "text/plain"):
296
+
// allowed
297
+
default:
298
+
l.Error("attempted to serve disallowed file type", "mimetype", mimeType)
299
+
writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden)
300
return
301
}
302
···
683
}
684
685
// add perms for this user to access the repo
686
+
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
687
if err != nil {
688
l.Error("adding repo permissions", "error", err.Error())
689
writeError(w, err.Error(), http.StatusInternalServerError)
···
901
}
902
903
// add perms for this user to access the repo
904
+
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
905
if err != nil {
906
l.Error("adding repo permissions", "error", err.Error())
907
writeError(w, err.Error(), http.StatusInternalServerError)
···
1155
}
1156
h.jc.AddDid(did)
1157
1158
+
if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil {
1159
l.Error("adding member", "error", err.Error())
1160
writeError(w, err.Error(), http.StatusInternalServerError)
1161
return
···
1193
h.jc.AddDid(data.Did)
1194
1195
repoName, _ := securejoin.SecureJoin(ownerDid, repo)
1196
+
if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil {
1197
l.Error("adding repo collaborator", "error", err.Error())
1198
writeError(w, err.Error(), http.StatusInternalServerError)
1199
return
···
1290
}
1291
h.jc.AddDid(data.Did)
1292
1293
+
if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil {
1294
l.Error("adding owner", "error", err.Error())
1295
writeError(w, err.Error(), http.StatusInternalServerError)
1296
return
+1
knotserver/server.go
+1
knotserver/server.go
-5
knotserver/util.go
-5
knotserver/util.go
···
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
securejoin "github.com/cyphar/filepath-securejoin"
10
"github.com/go-chi/chi/v5"
11
-
"github.com/microcosm-cc/bluemonday"
12
)
13
-
14
-
func sanitize(content []byte) []byte {
15
-
return bluemonday.UGCPolicy().SanitizeBytes([]byte(content))
16
-
}
17
18
func didPath(r *http.Request) string {
19
did := chi.URLParam(r, "did")
+149
knotserver/xrpc/router.go
+149
knotserver/xrpc/router.go
···
···
1
+
package xrpc
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"fmt"
7
+
"log/slog"
8
+
"net/http"
9
+
"strings"
10
+
11
+
"tangled.sh/tangled.sh/core/api/tangled"
12
+
"tangled.sh/tangled.sh/core/idresolver"
13
+
"tangled.sh/tangled.sh/core/jetstream"
14
+
"tangled.sh/tangled.sh/core/knotserver/config"
15
+
"tangled.sh/tangled.sh/core/knotserver/db"
16
+
"tangled.sh/tangled.sh/core/notifier"
17
+
"tangled.sh/tangled.sh/core/rbac"
18
+
19
+
"github.com/bluesky-social/indigo/atproto/auth"
20
+
"github.com/go-chi/chi/v5"
21
+
)
22
+
23
+
type Xrpc struct {
24
+
Config *config.Config
25
+
Db *db.DB
26
+
Ingester *jetstream.JetstreamClient
27
+
Enforcer *rbac.Enforcer
28
+
Logger *slog.Logger
29
+
Notifier *notifier.Notifier
30
+
Resolver *idresolver.Resolver
31
+
}
32
+
33
+
func (x *Xrpc) Router() http.Handler {
34
+
r := chi.NewRouter()
35
+
36
+
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
37
+
38
+
return r
39
+
}
40
+
41
+
func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler {
42
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43
+
l := x.Logger.With("url", r.URL)
44
+
45
+
token := r.Header.Get("Authorization")
46
+
token = strings.TrimPrefix(token, "Bearer ")
47
+
48
+
s := auth.ServiceAuthValidator{
49
+
Audience: x.Config.Server.Did().String(),
50
+
Dir: x.Resolver.Directory(),
51
+
}
52
+
53
+
did, err := s.Validate(r.Context(), token, nil)
54
+
if err != nil {
55
+
l.Error("signature verification failed", "err", err)
56
+
writeError(w, AuthError(err), http.StatusForbidden)
57
+
return
58
+
}
59
+
60
+
r = r.WithContext(
61
+
context.WithValue(r.Context(), ActorDid, did),
62
+
)
63
+
64
+
next.ServeHTTP(w, r)
65
+
})
66
+
}
67
+
68
+
type XrpcError struct {
69
+
Tag string `json:"error"`
70
+
Message string `json:"message"`
71
+
}
72
+
73
+
func NewXrpcError(opts ...ErrOpt) XrpcError {
74
+
x := XrpcError{}
75
+
for _, o := range opts {
76
+
o(&x)
77
+
}
78
+
79
+
return x
80
+
}
81
+
82
+
type ErrOpt = func(xerr *XrpcError)
83
+
84
+
func WithTag(tag string) ErrOpt {
85
+
return func(xerr *XrpcError) {
86
+
xerr.Tag = tag
87
+
}
88
+
}
89
+
90
+
func WithMessage[S ~string](s S) ErrOpt {
91
+
return func(xerr *XrpcError) {
92
+
xerr.Message = string(s)
93
+
}
94
+
}
95
+
96
+
func WithError(e error) ErrOpt {
97
+
return func(xerr *XrpcError) {
98
+
xerr.Message = e.Error()
99
+
}
100
+
}
101
+
102
+
var MissingActorDidError = NewXrpcError(
103
+
WithTag("MissingActorDid"),
104
+
WithMessage("actor DID not supplied"),
105
+
)
106
+
107
+
var AuthError = func(err error) XrpcError {
108
+
return NewXrpcError(
109
+
WithTag("Auth"),
110
+
WithError(fmt.Errorf("signature verification failed: %w", err)),
111
+
)
112
+
}
113
+
114
+
var InvalidRepoError = func(r string) XrpcError {
115
+
return NewXrpcError(
116
+
WithTag("InvalidRepo"),
117
+
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
118
+
)
119
+
}
120
+
121
+
var AccessControlError = func(d string) XrpcError {
122
+
return NewXrpcError(
123
+
WithTag("AccessControl"),
124
+
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
125
+
)
126
+
}
127
+
128
+
var GitError = func(e error) XrpcError {
129
+
return NewXrpcError(
130
+
WithTag("Git"),
131
+
WithError(fmt.Errorf("git error: %w", e)),
132
+
)
133
+
}
134
+
135
+
func GenericError(err error) XrpcError {
136
+
return NewXrpcError(
137
+
WithTag("Generic"),
138
+
WithError(err),
139
+
)
140
+
}
141
+
142
+
// this is slightly different from http_util::write_error to follow the spec:
143
+
//
144
+
// the json object returned must include an "error" and a "message"
145
+
func writeError(w http.ResponseWriter, e XrpcError, status int) {
146
+
w.Header().Set("Content-Type", "application/json")
147
+
w.WriteHeader(status)
148
+
json.NewEncoder(w).Encode(e)
149
+
}
+87
knotserver/xrpc/set_default_branch.go
+87
knotserver/xrpc/set_default_branch.go
···
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
8
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"github.com/bluesky-social/indigo/xrpc"
11
+
securejoin "github.com/cyphar/filepath-securejoin"
12
+
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.sh/tangled.sh/core/knotserver/git"
14
+
"tangled.sh/tangled.sh/core/rbac"
15
+
)
16
+
17
+
const ActorDid string = "ActorDid"
18
+
19
+
func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
20
+
l := x.Logger
21
+
fail := func(e XrpcError) {
22
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
23
+
writeError(w, e, http.StatusBadRequest)
24
+
}
25
+
26
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
27
+
if !ok {
28
+
fail(MissingActorDidError)
29
+
return
30
+
}
31
+
32
+
var data tangled.RepoSetDefaultBranch_Input
33
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
34
+
fail(GenericError(err))
35
+
return
36
+
}
37
+
38
+
// unfortunately we have to resolve repo-at here
39
+
repoAt, err := syntax.ParseATURI(data.Repo)
40
+
if err != nil {
41
+
fail(InvalidRepoError(data.Repo))
42
+
return
43
+
}
44
+
45
+
// resolve this aturi to extract the repo record
46
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
47
+
if err != nil || ident.Handle.IsInvalidHandle() {
48
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
49
+
return
50
+
}
51
+
52
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
53
+
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
54
+
if err != nil {
55
+
fail(GenericError(err))
56
+
return
57
+
}
58
+
59
+
repo := resp.Value.Val.(*tangled.Repo)
60
+
didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name)
61
+
if err != nil {
62
+
fail(GenericError(err))
63
+
return
64
+
}
65
+
66
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
67
+
l.Error("insufficent permissions", "did", actorDid.String())
68
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
69
+
return
70
+
}
71
+
72
+
path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath)
73
+
gr, err := git.PlainOpen(path)
74
+
if err != nil {
75
+
fail(InvalidRepoError(data.Repo))
76
+
return
77
+
}
78
+
79
+
err = gr.SetDefaultBranch(data.DefaultBranch)
80
+
if err != nil {
81
+
l.Error("setting default branch", "error", err.Error())
82
+
writeError(w, GitError(err), http.StatusInternalServerError)
83
+
return
84
+
}
85
+
86
+
w.WriteHeader(http.StatusOK)
87
+
}
-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
-
}
···
+263
lexicons/pipeline/pipeline.json
+263
lexicons/pipeline/pipeline.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.pipeline",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"triggerMetadata",
14
+
"workflows"
15
+
],
16
+
"properties": {
17
+
"triggerMetadata": {
18
+
"type": "ref",
19
+
"ref": "#triggerMetadata"
20
+
},
21
+
"workflows": {
22
+
"type": "array",
23
+
"items": {
24
+
"type": "ref",
25
+
"ref": "#workflow"
26
+
}
27
+
}
28
+
}
29
+
}
30
+
},
31
+
"triggerMetadata": {
32
+
"type": "object",
33
+
"required": [
34
+
"kind",
35
+
"repo"
36
+
],
37
+
"properties": {
38
+
"kind": {
39
+
"type": "string",
40
+
"enum": [
41
+
"push",
42
+
"pull_request",
43
+
"manual"
44
+
]
45
+
},
46
+
"repo": {
47
+
"type": "ref",
48
+
"ref": "#triggerRepo"
49
+
},
50
+
"push": {
51
+
"type": "ref",
52
+
"ref": "#pushTriggerData"
53
+
},
54
+
"pullRequest": {
55
+
"type": "ref",
56
+
"ref": "#pullRequestTriggerData"
57
+
},
58
+
"manual": {
59
+
"type": "ref",
60
+
"ref": "#manualTriggerData"
61
+
}
62
+
}
63
+
},
64
+
"triggerRepo": {
65
+
"type": "object",
66
+
"required": [
67
+
"knot",
68
+
"did",
69
+
"repo",
70
+
"defaultBranch"
71
+
],
72
+
"properties": {
73
+
"knot": {
74
+
"type": "string"
75
+
},
76
+
"did": {
77
+
"type": "string",
78
+
"format": "did"
79
+
},
80
+
"repo": {
81
+
"type": "string"
82
+
},
83
+
"defaultBranch": {
84
+
"type": "string"
85
+
}
86
+
}
87
+
},
88
+
"pushTriggerData": {
89
+
"type": "object",
90
+
"required": [
91
+
"ref",
92
+
"newSha",
93
+
"oldSha"
94
+
],
95
+
"properties": {
96
+
"ref": {
97
+
"type": "string"
98
+
},
99
+
"newSha": {
100
+
"type": "string",
101
+
"minLength": 40,
102
+
"maxLength": 40
103
+
},
104
+
"oldSha": {
105
+
"type": "string",
106
+
"minLength": 40,
107
+
"maxLength": 40
108
+
}
109
+
}
110
+
},
111
+
"pullRequestTriggerData": {
112
+
"type": "object",
113
+
"required": [
114
+
"sourceBranch",
115
+
"targetBranch",
116
+
"sourceSha",
117
+
"action"
118
+
],
119
+
"properties": {
120
+
"sourceBranch": {
121
+
"type": "string"
122
+
},
123
+
"targetBranch": {
124
+
"type": "string"
125
+
},
126
+
"sourceSha": {
127
+
"type": "string",
128
+
"minLength": 40,
129
+
"maxLength": 40
130
+
},
131
+
"action": {
132
+
"type": "string"
133
+
}
134
+
}
135
+
},
136
+
"manualTriggerData": {
137
+
"type": "object",
138
+
"properties": {
139
+
"inputs": {
140
+
"type": "array",
141
+
"items": {
142
+
"type": "ref",
143
+
"ref": "#pair"
144
+
}
145
+
}
146
+
}
147
+
},
148
+
"workflow": {
149
+
"type": "object",
150
+
"required": [
151
+
"name",
152
+
"dependencies",
153
+
"steps",
154
+
"environment",
155
+
"clone"
156
+
],
157
+
"properties": {
158
+
"name": {
159
+
"type": "string"
160
+
},
161
+
"dependencies": {
162
+
"type": "array",
163
+
"items": {
164
+
"type": "ref",
165
+
"ref": "#dependency"
166
+
}
167
+
},
168
+
"steps": {
169
+
"type": "array",
170
+
"items": {
171
+
"type": "ref",
172
+
"ref": "#step"
173
+
}
174
+
},
175
+
"environment": {
176
+
"type": "array",
177
+
"items": {
178
+
"type": "ref",
179
+
"ref": "#pair"
180
+
}
181
+
},
182
+
"clone": {
183
+
"type": "ref",
184
+
"ref": "#cloneOpts"
185
+
}
186
+
}
187
+
},
188
+
"dependency": {
189
+
"type": "object",
190
+
"required": [
191
+
"registry",
192
+
"packages"
193
+
],
194
+
"properties": {
195
+
"registry": {
196
+
"type": "string"
197
+
},
198
+
"packages": {
199
+
"type": "array",
200
+
"items": {
201
+
"type": "string"
202
+
}
203
+
}
204
+
}
205
+
},
206
+
"cloneOpts": {
207
+
"type": "object",
208
+
"required": [
209
+
"skip",
210
+
"depth",
211
+
"submodules"
212
+
],
213
+
"properties": {
214
+
"skip": {
215
+
"type": "boolean"
216
+
},
217
+
"depth": {
218
+
"type": "integer"
219
+
},
220
+
"submodules": {
221
+
"type": "boolean"
222
+
}
223
+
}
224
+
},
225
+
"step": {
226
+
"type": "object",
227
+
"required": [
228
+
"name",
229
+
"command"
230
+
],
231
+
"properties": {
232
+
"name": {
233
+
"type": "string"
234
+
},
235
+
"command": {
236
+
"type": "string"
237
+
},
238
+
"environment": {
239
+
"type": "array",
240
+
"items": {
241
+
"type": "ref",
242
+
"ref": "#pair"
243
+
}
244
+
}
245
+
}
246
+
},
247
+
"pair": {
248
+
"type": "object",
249
+
"required": [
250
+
"key",
251
+
"value"
252
+
],
253
+
"properties": {
254
+
"key": {
255
+
"type": "string"
256
+
},
257
+
"value": {
258
+
"type": "string"
259
+
}
260
+
}
261
+
}
262
+
}
263
+
}
-263
lexicons/pipeline.json
-263
lexicons/pipeline.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.pipeline",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"triggerMetadata",
14
-
"workflows"
15
-
],
16
-
"properties": {
17
-
"triggerMetadata": {
18
-
"type": "ref",
19
-
"ref": "#triggerMetadata"
20
-
},
21
-
"workflows": {
22
-
"type": "array",
23
-
"items": {
24
-
"type": "ref",
25
-
"ref": "#workflow"
26
-
}
27
-
}
28
-
}
29
-
}
30
-
},
31
-
"triggerMetadata": {
32
-
"type": "object",
33
-
"required": [
34
-
"kind",
35
-
"repo"
36
-
],
37
-
"properties": {
38
-
"kind": {
39
-
"type": "string",
40
-
"enum": [
41
-
"push",
42
-
"pull_request",
43
-
"manual"
44
-
]
45
-
},
46
-
"repo": {
47
-
"type": "ref",
48
-
"ref": "#triggerRepo"
49
-
},
50
-
"push": {
51
-
"type": "ref",
52
-
"ref": "#pushTriggerData"
53
-
},
54
-
"pullRequest": {
55
-
"type": "ref",
56
-
"ref": "#pullRequestTriggerData"
57
-
},
58
-
"manual": {
59
-
"type": "ref",
60
-
"ref": "#manualTriggerData"
61
-
}
62
-
}
63
-
},
64
-
"triggerRepo": {
65
-
"type": "object",
66
-
"required": [
67
-
"knot",
68
-
"did",
69
-
"repo",
70
-
"defaultBranch"
71
-
],
72
-
"properties": {
73
-
"knot": {
74
-
"type": "string"
75
-
},
76
-
"did": {
77
-
"type": "string",
78
-
"format": "did"
79
-
},
80
-
"repo": {
81
-
"type": "string"
82
-
},
83
-
"defaultBranch": {
84
-
"type": "string"
85
-
}
86
-
}
87
-
},
88
-
"pushTriggerData": {
89
-
"type": "object",
90
-
"required": [
91
-
"ref",
92
-
"newSha",
93
-
"oldSha"
94
-
],
95
-
"properties": {
96
-
"ref": {
97
-
"type": "string"
98
-
},
99
-
"newSha": {
100
-
"type": "string",
101
-
"minLength": 40,
102
-
"maxLength": 40
103
-
},
104
-
"oldSha": {
105
-
"type": "string",
106
-
"minLength": 40,
107
-
"maxLength": 40
108
-
}
109
-
}
110
-
},
111
-
"pullRequestTriggerData": {
112
-
"type": "object",
113
-
"required": [
114
-
"sourceBranch",
115
-
"targetBranch",
116
-
"sourceSha",
117
-
"action"
118
-
],
119
-
"properties": {
120
-
"sourceBranch": {
121
-
"type": "string"
122
-
},
123
-
"targetBranch": {
124
-
"type": "string"
125
-
},
126
-
"sourceSha": {
127
-
"type": "string",
128
-
"minLength": 40,
129
-
"maxLength": 40
130
-
},
131
-
"action": {
132
-
"type": "string"
133
-
}
134
-
}
135
-
},
136
-
"manualTriggerData": {
137
-
"type": "object",
138
-
"properties": {
139
-
"inputs": {
140
-
"type": "array",
141
-
"items": {
142
-
"type": "ref",
143
-
"ref": "#pair"
144
-
}
145
-
}
146
-
}
147
-
},
148
-
"workflow": {
149
-
"type": "object",
150
-
"required": [
151
-
"name",
152
-
"dependencies",
153
-
"steps",
154
-
"environment",
155
-
"clone"
156
-
],
157
-
"properties": {
158
-
"name": {
159
-
"type": "string"
160
-
},
161
-
"dependencies": {
162
-
"type": "array",
163
-
"items": {
164
-
"type": "ref",
165
-
"ref": "#dependency"
166
-
}
167
-
},
168
-
"steps": {
169
-
"type": "array",
170
-
"items": {
171
-
"type": "ref",
172
-
"ref": "#step"
173
-
}
174
-
},
175
-
"environment": {
176
-
"type": "array",
177
-
"items": {
178
-
"type": "ref",
179
-
"ref": "#pair"
180
-
}
181
-
},
182
-
"clone": {
183
-
"type": "ref",
184
-
"ref": "#cloneOpts"
185
-
}
186
-
}
187
-
},
188
-
"dependency": {
189
-
"type": "object",
190
-
"required": [
191
-
"registry",
192
-
"packages"
193
-
],
194
-
"properties": {
195
-
"registry": {
196
-
"type": "string"
197
-
},
198
-
"packages": {
199
-
"type": "array",
200
-
"items": {
201
-
"type": "string"
202
-
}
203
-
}
204
-
}
205
-
},
206
-
"cloneOpts": {
207
-
"type": "object",
208
-
"required": [
209
-
"skip",
210
-
"depth",
211
-
"submodules"
212
-
],
213
-
"properties": {
214
-
"skip": {
215
-
"type": "boolean"
216
-
},
217
-
"depth": {
218
-
"type": "integer"
219
-
},
220
-
"submodules": {
221
-
"type": "boolean"
222
-
}
223
-
}
224
-
},
225
-
"step": {
226
-
"type": "object",
227
-
"required": [
228
-
"name",
229
-
"command"
230
-
],
231
-
"properties": {
232
-
"name": {
233
-
"type": "string"
234
-
},
235
-
"command": {
236
-
"type": "string"
237
-
},
238
-
"environment": {
239
-
"type": "array",
240
-
"items": {
241
-
"type": "ref",
242
-
"ref": "#pair"
243
-
}
244
-
}
245
-
}
246
-
},
247
-
"pair": {
248
-
"type": "object",
249
-
"required": [
250
-
"key",
251
-
"value"
252
-
],
253
-
"properties": {
254
-
"key": {
255
-
"type": "string"
256
-
},
257
-
"value": {
258
-
"type": "string"
259
-
}
260
-
}
261
-
}
262
-
}
263
-
}
···
+37
lexicons/repo/addSecret.json
+37
lexicons/repo/addSecret.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.addSecret",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Add a CI secret",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"key",
15
+
"value"
16
+
],
17
+
"properties": {
18
+
"repo": {
19
+
"type": "string",
20
+
"format": "at-uri"
21
+
},
22
+
"key": {
23
+
"type": "string",
24
+
"maxLength": 50,
25
+
"minLength": 1
26
+
},
27
+
"value": {
28
+
"type": "string",
29
+
"maxLength": 200,
30
+
"minLength": 1
31
+
}
32
+
}
33
+
}
34
+
}
35
+
}
36
+
}
37
+
}
+52
lexicons/repo/artifact.json
+52
lexicons/repo/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
+
}
+36
lexicons/repo/collaborator.json
+36
lexicons/repo/collaborator.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.collaborator",
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
+
"repo",
15
+
"createdAt"
16
+
],
17
+
"properties": {
18
+
"subject": {
19
+
"type": "string",
20
+
"format": "did"
21
+
},
22
+
"repo": {
23
+
"type": "string",
24
+
"description": "repo to add this user to",
25
+
"format": "at-uri"
26
+
},
27
+
"createdAt": {
28
+
"type": "string",
29
+
"format": "datetime"
30
+
}
31
+
}
32
+
}
33
+
}
34
+
}
35
+
}
36
+
+29
lexicons/repo/defaultBranch.json
+29
lexicons/repo/defaultBranch.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.setDefaultBranch",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Set the default branch for a repository",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"defaultBranch"
15
+
],
16
+
"properties": {
17
+
"repo": {
18
+
"type": "string",
19
+
"format": "at-uri"
20
+
},
21
+
"defaultBranch": {
22
+
"type": "string"
23
+
}
24
+
}
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
+67
lexicons/repo/listSecrets.json
+67
lexicons/repo/listSecrets.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.listSecrets",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": [
10
+
"repo"
11
+
],
12
+
"properties": {
13
+
"repo": {
14
+
"type": "string",
15
+
"format": "at-uri"
16
+
}
17
+
}
18
+
},
19
+
"output": {
20
+
"encoding": "application/json",
21
+
"schema": {
22
+
"type": "object",
23
+
"required": [
24
+
"secrets"
25
+
],
26
+
"properties": {
27
+
"secrets": {
28
+
"type": "array",
29
+
"items": {
30
+
"type": "ref",
31
+
"ref": "#secret"
32
+
}
33
+
}
34
+
}
35
+
}
36
+
}
37
+
},
38
+
"secret": {
39
+
"type": "object",
40
+
"required": [
41
+
"repo",
42
+
"key",
43
+
"createdAt",
44
+
"createdBy"
45
+
],
46
+
"properties": {
47
+
"repo": {
48
+
"type": "string",
49
+
"format": "at-uri"
50
+
},
51
+
"key": {
52
+
"type": "string",
53
+
"maxLength": 50,
54
+
"minLength": 1
55
+
},
56
+
"createdAt": {
57
+
"type": "string",
58
+
"format": "datetime"
59
+
},
60
+
"createdBy": {
61
+
"type": "string",
62
+
"format": "did"
63
+
}
64
+
}
65
+
}
66
+
}
67
+
}
+31
lexicons/repo/removeSecret.json
+31
lexicons/repo/removeSecret.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.removeSecret",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Remove a CI secret",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"key"
15
+
],
16
+
"properties": {
17
+
"repo": {
18
+
"type": "string",
19
+
"format": "at-uri"
20
+
},
21
+
"key": {
22
+
"type": "string",
23
+
"maxLength": 50,
24
+
"minLength": 1
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
30
+
}
31
+
}
+54
lexicons/repo/repo.json
+54
lexicons/repo/repo.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo",
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
+
"knot",
15
+
"owner",
16
+
"createdAt"
17
+
],
18
+
"properties": {
19
+
"name": {
20
+
"type": "string",
21
+
"description": "name of the repo"
22
+
},
23
+
"owner": {
24
+
"type": "string",
25
+
"format": "did"
26
+
},
27
+
"knot": {
28
+
"type": "string",
29
+
"description": "knot where the repo was created"
30
+
},
31
+
"spindle": {
32
+
"type": "string",
33
+
"description": "CI runner to send jobs to and receive results from"
34
+
},
35
+
"description": {
36
+
"type": "string",
37
+
"format": "datetime",
38
+
"minGraphemes": 1,
39
+
"maxGraphemes": 140
40
+
},
41
+
"source": {
42
+
"type": "string",
43
+
"format": "uri",
44
+
"description": "source of the repo"
45
+
},
46
+
"createdAt": {
47
+
"type": "string",
48
+
"format": "datetime"
49
+
}
50
+
}
51
+
}
52
+
}
53
+
}
54
+
}
-54
lexicons/repo.json
-54
lexicons/repo.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo",
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
-
"knot",
15
-
"owner",
16
-
"createdAt"
17
-
],
18
-
"properties": {
19
-
"name": {
20
-
"type": "string",
21
-
"description": "name of the repo"
22
-
},
23
-
"owner": {
24
-
"type": "string",
25
-
"format": "did"
26
-
},
27
-
"knot": {
28
-
"type": "string",
29
-
"description": "knot where the repo was created"
30
-
},
31
-
"spindle": {
32
-
"type": "string",
33
-
"description": "CI runner to send jobs to and receive results from"
34
-
},
35
-
"description": {
36
-
"type": "string",
37
-
"format": "datetime",
38
-
"minGraphemes": 1,
39
-
"maxGraphemes": 140
40
-
},
41
-
"source": {
42
-
"type": "string",
43
-
"format": "uri",
44
-
"description": "source of the repo"
45
-
},
46
-
"createdAt": {
47
-
"type": "string",
48
-
"format": "datetime"
49
-
}
50
-
}
51
-
}
52
-
}
53
-
}
54
-
}
···
+25
lexicons/spindle/spindle.json
+25
lexicons/spindle/spindle.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.spindle",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "any",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"createdAt"
14
+
],
15
+
"properties": {
16
+
"createdAt": {
17
+
"type": "string",
18
+
"format": "datetime"
19
+
}
20
+
}
21
+
}
22
+
}
23
+
}
24
+
}
25
+
-25
lexicons/spindle.json
-25
lexicons/spindle.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.spindle",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "any",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"createdAt"
14
-
],
15
-
"properties": {
16
-
"createdAt": {
17
-
"type": "string",
18
-
"format": "datetime"
19
-
}
20
-
}
21
-
}
22
-
}
23
-
}
24
-
}
25
-
···
+40
lexicons/string/string.json
+40
lexicons/string/string.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.string",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"filename",
14
+
"description",
15
+
"createdAt",
16
+
"contents"
17
+
],
18
+
"properties": {
19
+
"filename": {
20
+
"type": "string",
21
+
"maxGraphemes": 140,
22
+
"minGraphemes": 1
23
+
},
24
+
"description": {
25
+
"type": "string",
26
+
"maxGraphemes": 280
27
+
},
28
+
"createdAt": {
29
+
"type": "string",
30
+
"format": "datetime"
31
+
},
32
+
"contents": {
33
+
"type": "string",
34
+
"minGraphemes": 1
35
+
}
36
+
}
37
+
}
38
+
}
39
+
}
40
+
}
+119
-59
nix/gomod2nix.toml
+119
-59
nix/gomod2nix.toml
···
11
version = "v0.6.2"
12
hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU="
13
[mod."github.com/ProtonMail/go-crypto"]
14
-
version = "v1.2.0"
15
-
hash = "sha256-5fKgWUz6BoyFNNZ1OD9QjhBrhNEBCuVfO2WqH+X59oo="
16
[mod."github.com/alecthomas/chroma/v2"]
17
version = "v2.19.0"
18
hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM="
19
replaced = "github.com/oppiliappan/chroma/v2"
20
[mod."github.com/anmitsu/go-shlex"]
21
version = "v0.0.0-20200514113438-38f4b401e2be"
22
hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54="
···
34
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
35
replaced = "tangled.sh/oppi.li/go-gitdiff"
36
[mod."github.com/bluesky-social/indigo"]
37
-
version = "v0.0.0-20250520232546-236dd575c91e"
38
-
hash = "sha256-SmwhGkAKcB/oGwYP68U5192fAUhui6D0GWYiJOeB1/0="
39
[mod."github.com/bluesky-social/jetstream"]
40
version = "v0.0.0-20241210005130-ea96859b93d1"
41
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
···
51
[mod."github.com/casbin/govaluate"]
52
version = "v1.3.0"
53
hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA="
54
[mod."github.com/cespare/xxhash/v2"]
55
version = "v2.3.0"
56
hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY="
57
[mod."github.com/cloudflare/circl"]
58
-
version = "v1.6.0"
59
-
hash = "sha256-a+SVfnHYC8Fb+NQLboNg5P9sry+WutzuNetVHFVAAo0="
60
[mod."github.com/containerd/errdefs"]
61
version = "v1.0.0"
62
hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI="
···
105
[mod."github.com/felixge/httpsnoop"]
106
version = "v1.0.4"
107
hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c="
108
[mod."github.com/gliderlabs/ssh"]
109
version = "v0.3.8"
110
hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc="
···
127
version = "v5.17.0"
128
hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ="
129
replaced = "github.com/oppiliappan/go-git/v5"
130
[mod."github.com/go-logr/logr"]
131
-
version = "v1.4.2"
132
-
hash = "sha256-/W6qGilFlZNTb9Uq48xGZ4IbsVeSwJiAMLw4wiNYHLI="
133
[mod."github.com/go-logr/stdr"]
134
version = "v1.2.2"
135
hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE="
136
[mod."github.com/go-redis/cache/v9"]
137
version = "v9.0.0"
138
hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY="
139
[mod."github.com/goccy/go-json"]
140
version = "v0.10.5"
141
hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw="
···
143
version = "v1.3.2"
144
hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c="
145
[mod."github.com/golang-jwt/jwt/v5"]
146
-
version = "v5.2.2"
147
-
hash = "sha256-C0MhDguxWR6dQUrNVQ5xaFUReSV6CVEBAijG3b4wnX4="
148
[mod."github.com/golang/groupcache"]
149
version = "v0.0.0-20241129210726-2c02b8208cf8"
150
hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74="
151
[mod."github.com/google/uuid"]
152
version = "v1.6.0"
153
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
···
161
version = "v1.4.0"
162
hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g="
163
[mod."github.com/gorilla/websocket"]
164
-
version = "v1.5.3"
165
-
hash = "sha256-vTIGEFMEi+30ZdO6ffMNJ/kId6pZs5bbyqov8xe9BM0="
166
[mod."github.com/hashicorp/go-cleanhttp"]
167
version = "v0.5.2"
168
hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ="
169
[mod."github.com/hashicorp/go-retryablehttp"]
170
-
version = "v0.7.7"
171
-
hash = "sha256-XZjxncyLPwy6YBHR3DF5bEl1y72or0JDUncTIsb/eIU="
172
[mod."github.com/hashicorp/golang-lru"]
173
version = "v1.0.2"
174
hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="
175
[mod."github.com/hashicorp/golang-lru/v2"]
176
version = "v2.0.7"
177
hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g="
178
[mod."github.com/hiddeco/sshsig"]
179
version = "v0.2.0"
180
hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU="
···
185
version = "v0.0.4"
186
hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU="
187
[mod."github.com/ipfs/boxo"]
188
-
version = "v0.30.0"
189
-
hash = "sha256-PWH+nlIZZlqB/PuiBX9X4McLZF4gKR1MEnjvutKT848="
190
[mod."github.com/ipfs/go-block-format"]
191
-
version = "v0.2.1"
192
-
hash = "sha256-npEV0Axe6zJlzN00/GwiegE9HKsuDR6RhsAfPyphOl8="
193
[mod."github.com/ipfs/go-cid"]
194
version = "v0.5.0"
195
hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk="
···
203
version = "v1.1.1"
204
hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY="
205
[mod."github.com/ipfs/go-ipld-cbor"]
206
-
version = "v0.2.0"
207
-
hash = "sha256-bvHFCIQqim3/+xzl1bld3NxKY8WoeCO3HpdTfUsXvlc="
208
[mod."github.com/ipfs/go-ipld-format"]
209
-
version = "v0.6.1"
210
-
hash = "sha256-v1zLYYGaoDxsgOW5joQGWHEHZoJjIXc6tLVgTomZ2z4="
211
[mod."github.com/ipfs/go-log"]
212
version = "v1.0.5"
213
hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4="
···
224
version = "v1.18.0"
225
hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk="
226
[mod."github.com/klauspost/cpuid/v2"]
227
-
version = "v2.2.10"
228
-
hash = "sha256-o21Tk5sD7WhhLUoqSkymnjLbzxl0mDJCTC1ApfZJrC0="
229
[mod."github.com/lestrrat-go/blackmagic"]
230
-
version = "v1.0.3"
231
-
hash = "sha256-1wyfD6fPopJF/UmzfAEa0N1zuUzVuHIpdcxks1kqxxw="
232
[mod."github.com/lestrrat-go/httpcc"]
233
version = "v1.0.1"
234
hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos="
···
256
[mod."github.com/minio/sha256-simd"]
257
version = "v1.0.1"
258
hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA="
259
[mod."github.com/moby/docker-image-spec"]
260
version = "v1.3.1"
261
hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs="
···
289
[mod."github.com/munnerz/goautoneg"]
290
version = "v0.0.0-20191010083416-a7dc8b61c822"
291
hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q="
292
[mod."github.com/opencontainers/go-digest"]
293
version = "v1.0.0"
294
hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ="
···
296
version = "v1.1.1"
297
hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8="
298
[mod."github.com/opentracing/opentracing-go"]
299
-
version = "v1.2.0"
300
-
hash = "sha256-kKTKFGXOsCF6QdVzI++GgaRzv2W+kWq5uDXOJChvLxM="
301
[mod."github.com/pjbgf/sha1cd"]
302
version = "v0.3.2"
303
hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk="
···
320
version = "v0.6.2"
321
hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ="
322
[mod."github.com/prometheus/common"]
323
-
version = "v0.63.0"
324
-
hash = "sha256-TbUZNkN4ZA7eC/MlL1v2V5OL28QRnftSuaWQZ944zBE="
325
[mod."github.com/prometheus/procfs"]
326
version = "v0.16.1"
327
hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo="
328
[mod."github.com/redis/go-redis/v9"]
329
-
version = "v9.3.0"
330
-
hash = "sha256-PNXDX3BH92d2jL/AkdK0eWMorh387Y6duwYNhsqNe+w="
331
[mod."github.com/resend/resend-go/v2"]
332
version = "v2.15.0"
333
hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg="
334
[mod."github.com/segmentio/asm"]
335
version = "v1.2.0"
336
hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs="
···
375
version = "v1.1.0"
376
hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
377
[mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"]
378
-
version = "v0.61.0"
379
-
hash = "sha256-4pfXD7ErXhexSynXiEEQSAkWoPwHd7PEDE3M1Zi5gLM="
380
[mod."go.opentelemetry.io/otel"]
381
-
version = "v1.36.0"
382
-
hash = "sha256-j8wojdCtKal3LKojanHA8KXXQ0FkbWONpO8tUxpJDko="
383
[mod."go.opentelemetry.io/otel/metric"]
384
-
version = "v1.36.0"
385
-
hash = "sha256-z6Uqi4HhUljWIYd58svKK5MqcGbpcac+/M8JeTrUtJ8="
386
[mod."go.opentelemetry.io/otel/trace"]
387
-
version = "v1.36.0"
388
-
hash = "sha256-owWD9x1lp8aIJqYt058BXPUsIMHdk3RI0escso0BxwA="
389
[mod."go.opentelemetry.io/proto/otlp"]
390
version = "v1.6.0"
391
hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg="
···
399
version = "v1.27.0"
400
hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU="
401
[mod."golang.org/x/crypto"]
402
-
version = "v0.38.0"
403
-
hash = "sha256-5tTXlXQBlfW1sSNDAIalOpsERbTJlZqbwCIiih4T4rY="
404
[mod."golang.org/x/exp"]
405
-
version = "v0.0.0-20250408133849-7e4ce0ab07d0"
406
-
hash = "sha256-Lw/WupSM8gcq0JzPSAaBqj9l1uZ68ANhaIaQzPhRpy8="
407
[mod."golang.org/x/net"]
408
-
version = "v0.40.0"
409
-
hash = "sha256-BhDOHTP8RekXDQDf9HlORSmI2aPacLo53fRXtTgCUH8="
410
[mod."golang.org/x/sync"]
411
-
version = "v0.14.0"
412
-
hash = "sha256-YNQLeFMeXN9y0z4OyXV/LJ4hA54q+ljm1ytcy80O6r4="
413
[mod."golang.org/x/sys"]
414
-
version = "v0.33.0"
415
-
hash = "sha256-wlOzIOUgAiGAtdzhW/KPl/yUVSH/lvFZfs5XOuJ9LOQ="
416
[mod."golang.org/x/time"]
417
-
version = "v0.8.0"
418
-
hash = "sha256-EA+qRisDJDPQ2g4pcfP4RyQaB7CJKkAn68EbNfBzXdQ="
419
[mod."golang.org/x/xerrors"]
420
version = "v0.0.0-20240903120638-7835f813f4da"
421
hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo="
422
[mod."google.golang.org/genproto/googleapis/api"]
423
-
version = "v0.0.0-20250519155744-55703ea1f237"
424
-
hash = "sha256-ivktx8ipWgWZgchh4FjKoWL7kU8kl/TtIavtZq/F5SQ="
425
[mod."google.golang.org/genproto/googleapis/rpc"]
426
-
version = "v0.0.0-20250519155744-55703ea1f237"
427
hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM="
428
[mod."google.golang.org/grpc"]
429
-
version = "v1.72.1"
430
-
hash = "sha256-5JczomNvroKWtIYKDgXwaIaEfuNEK//MHPhJQiaxMXs="
431
[mod."google.golang.org/protobuf"]
432
version = "v1.36.6"
433
hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc="
···
450
version = "v1.4.1"
451
hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
452
[mod."tangled.sh/icyphox.sh/atproto-oauth"]
453
-
version = "v0.0.0-20250526154904-3906c5336421"
454
-
hash = "sha256-CvR8jic0YZfj0a8ubPj06FiMMR/1K9kHoZhLQw1LItM="
···
11
version = "v0.6.2"
12
hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU="
13
[mod."github.com/ProtonMail/go-crypto"]
14
+
version = "v1.3.0"
15
+
hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI="
16
+
[mod."github.com/alecthomas/assert/v2"]
17
+
version = "v2.11.0"
18
+
hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU="
19
[mod."github.com/alecthomas/chroma/v2"]
20
version = "v2.19.0"
21
hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM="
22
replaced = "github.com/oppiliappan/chroma/v2"
23
+
[mod."github.com/alecthomas/repr"]
24
+
version = "v0.4.0"
25
+
hash = "sha256-CyAzMSTfLGHDtfGXi91y7XMVpPUDNOKjsznb+osl9dU="
26
[mod."github.com/anmitsu/go-shlex"]
27
version = "v0.0.0-20200514113438-38f4b401e2be"
28
hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54="
···
40
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
41
replaced = "tangled.sh/oppi.li/go-gitdiff"
42
[mod."github.com/bluesky-social/indigo"]
43
+
version = "v0.0.0-20250724221105-5827c8fb61bb"
44
+
hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI="
45
[mod."github.com/bluesky-social/jetstream"]
46
version = "v0.0.0-20241210005130-ea96859b93d1"
47
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
···
57
[mod."github.com/casbin/govaluate"]
58
version = "v1.3.0"
59
hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA="
60
+
[mod."github.com/cenkalti/backoff/v4"]
61
+
version = "v4.3.0"
62
+
hash = "sha256-wfVjNZsGG1WoNC5aL+kdcy6QXPgZo4THAevZ1787md8="
63
[mod."github.com/cespare/xxhash/v2"]
64
version = "v2.3.0"
65
hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY="
66
[mod."github.com/cloudflare/circl"]
67
+
version = "v1.6.2-0.20250618153321-aa837fd1539d"
68
+
hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y="
69
[mod."github.com/containerd/errdefs"]
70
version = "v1.0.0"
71
hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI="
···
114
[mod."github.com/felixge/httpsnoop"]
115
version = "v1.0.4"
116
hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c="
117
+
[mod."github.com/fsnotify/fsnotify"]
118
+
version = "v1.6.0"
119
+
hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0="
120
[mod."github.com/gliderlabs/ssh"]
121
version = "v0.3.8"
122
hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc="
···
139
version = "v5.17.0"
140
hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ="
141
replaced = "github.com/oppiliappan/go-git/v5"
142
+
[mod."github.com/go-jose/go-jose/v3"]
143
+
version = "v3.0.4"
144
+
hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ="
145
[mod."github.com/go-logr/logr"]
146
+
version = "v1.4.3"
147
+
hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA="
148
[mod."github.com/go-logr/stdr"]
149
version = "v1.2.2"
150
hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE="
151
[mod."github.com/go-redis/cache/v9"]
152
version = "v9.0.0"
153
hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY="
154
+
[mod."github.com/go-test/deep"]
155
+
version = "v1.1.1"
156
+
hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8="
157
[mod."github.com/goccy/go-json"]
158
version = "v0.10.5"
159
hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw="
···
161
version = "v1.3.2"
162
hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c="
163
[mod."github.com/golang-jwt/jwt/v5"]
164
+
version = "v5.2.3"
165
+
hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo="
166
[mod."github.com/golang/groupcache"]
167
version = "v0.0.0-20241129210726-2c02b8208cf8"
168
hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74="
169
+
[mod."github.com/golang/mock"]
170
+
version = "v1.6.0"
171
+
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
172
[mod."github.com/google/uuid"]
173
version = "v1.6.0"
174
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
···
182
version = "v1.4.0"
183
hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g="
184
[mod."github.com/gorilla/websocket"]
185
+
version = "v1.5.4-0.20250319132907-e064f32e3674"
186
+
hash = "sha256-a8n6oe20JDpwThClgAyVhJDi6QVaS0qzT4PvRxlQ9to="
187
+
[mod."github.com/hashicorp/errwrap"]
188
+
version = "v1.1.0"
189
+
hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw="
190
[mod."github.com/hashicorp/go-cleanhttp"]
191
version = "v0.5.2"
192
hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ="
193
+
[mod."github.com/hashicorp/go-multierror"]
194
+
version = "v1.1.1"
195
+
hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA="
196
[mod."github.com/hashicorp/go-retryablehttp"]
197
+
version = "v0.7.8"
198
+
hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80="
199
+
[mod."github.com/hashicorp/go-secure-stdlib/parseutil"]
200
+
version = "v0.2.0"
201
+
hash = "sha256-mb27ZKw5VDTmNj1QJvxHVR0GyY7UdacLJ0jWDV3nQd8="
202
+
[mod."github.com/hashicorp/go-secure-stdlib/strutil"]
203
+
version = "v0.1.2"
204
+
hash = "sha256-UmCMzjamCW1d9KNvNzELqKf1ElHOXPz+ZtdJkI+DV0A="
205
+
[mod."github.com/hashicorp/go-sockaddr"]
206
+
version = "v1.0.7"
207
+
hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs="
208
[mod."github.com/hashicorp/golang-lru"]
209
version = "v1.0.2"
210
hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="
211
[mod."github.com/hashicorp/golang-lru/v2"]
212
version = "v2.0.7"
213
hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g="
214
+
[mod."github.com/hashicorp/hcl"]
215
+
version = "v1.0.1-vault-7"
216
+
hash = "sha256-xqYtjCJQVsg04Yj2Uy2Q5bi6X6cDRYhJD/SUEWaHMDM="
217
+
[mod."github.com/hexops/gotextdiff"]
218
+
version = "v1.0.3"
219
+
hash = "sha256-wVs5uJs2KHU1HnDCDdSe0vIgNZylvs8oNidDxwA3+O0="
220
[mod."github.com/hiddeco/sshsig"]
221
version = "v0.2.0"
222
hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU="
···
227
version = "v0.0.4"
228
hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU="
229
[mod."github.com/ipfs/boxo"]
230
+
version = "v0.33.0"
231
+
hash = "sha256-C85D/TjyoWNKvRFvl2A9hBjpDPhC//hGB2jRoDmXM38="
232
[mod."github.com/ipfs/go-block-format"]
233
+
version = "v0.2.2"
234
+
hash = "sha256-kz87tlGneqEARGnjA1Lb0K5CqD1lgxhBD68rfmzZZGU="
235
[mod."github.com/ipfs/go-cid"]
236
version = "v0.5.0"
237
hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk="
···
245
version = "v1.1.1"
246
hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY="
247
[mod."github.com/ipfs/go-ipld-cbor"]
248
+
version = "v0.2.1"
249
+
hash = "sha256-ONBX/YO/knnmp+12fC13KsKVeo/vdWOI3SDyqCBxRE4="
250
[mod."github.com/ipfs/go-ipld-format"]
251
+
version = "v0.6.2"
252
+
hash = "sha256-nGLM/n/hy+0q1VIzQUvF4D3aKvL238ALIEziQQhVMkU="
253
[mod."github.com/ipfs/go-log"]
254
version = "v1.0.5"
255
hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4="
···
266
version = "v1.18.0"
267
hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk="
268
[mod."github.com/klauspost/cpuid/v2"]
269
+
version = "v2.3.0"
270
+
hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc="
271
[mod."github.com/lestrrat-go/blackmagic"]
272
+
version = "v1.0.4"
273
+
hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8="
274
[mod."github.com/lestrrat-go/httpcc"]
275
version = "v1.0.1"
276
hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos="
···
298
[mod."github.com/minio/sha256-simd"]
299
version = "v1.0.1"
300
hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA="
301
+
[mod."github.com/mitchellh/mapstructure"]
302
+
version = "v1.5.0"
303
+
hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE="
304
[mod."github.com/moby/docker-image-spec"]
305
version = "v1.3.1"
306
hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs="
···
334
[mod."github.com/munnerz/goautoneg"]
335
version = "v0.0.0-20191010083416-a7dc8b61c822"
336
hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q="
337
+
[mod."github.com/onsi/gomega"]
338
+
version = "v1.37.0"
339
+
hash = "sha256-PfHFYp365MwBo+CUZs+mN5QEk3Kqe9xrBX+twWfIc9o="
340
+
[mod."github.com/openbao/openbao/api/v2"]
341
+
version = "v2.3.0"
342
+
hash = "sha256-1bIyvL3GdzPUfsM+gxuKMaH5jKxMaucZQgL6/DfbmDM="
343
[mod."github.com/opencontainers/go-digest"]
344
version = "v1.0.0"
345
hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ="
···
347
version = "v1.1.1"
348
hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8="
349
[mod."github.com/opentracing/opentracing-go"]
350
+
version = "v1.2.1-0.20220228012449-10b1cf09e00b"
351
+
hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw="
352
[mod."github.com/pjbgf/sha1cd"]
353
version = "v0.3.2"
354
hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk="
···
371
version = "v0.6.2"
372
hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ="
373
[mod."github.com/prometheus/common"]
374
+
version = "v0.64.0"
375
+
hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI="
376
[mod."github.com/prometheus/procfs"]
377
version = "v0.16.1"
378
hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo="
379
[mod."github.com/redis/go-redis/v9"]
380
+
version = "v9.7.3"
381
+
hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo="
382
[mod."github.com/resend/resend-go/v2"]
383
version = "v2.15.0"
384
hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg="
385
+
[mod."github.com/ryanuber/go-glob"]
386
+
version = "v1.0.0"
387
+
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
388
[mod."github.com/segmentio/asm"]
389
version = "v1.2.0"
390
hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs="
···
429
version = "v1.1.0"
430
hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
431
[mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"]
432
+
version = "v0.62.0"
433
+
hash = "sha256-WcDogpsvFxGOVqc+/ljlZ10nrOU2q0rPteukGyWWmfc="
434
[mod."go.opentelemetry.io/otel"]
435
+
version = "v1.37.0"
436
+
hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo="
437
+
[mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"]
438
+
version = "v1.33.0"
439
+
hash = "sha256-D5BMzmtN1d3pRnxIcvDOyQrjerK1JoavtYjJLhPKv/I="
440
[mod."go.opentelemetry.io/otel/metric"]
441
+
version = "v1.37.0"
442
+
hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg="
443
[mod."go.opentelemetry.io/otel/trace"]
444
+
version = "v1.37.0"
445
+
hash = "sha256-FBeLOb5qmIiE9VmbgCf1l/xpndBqHkRiaPt1PvoKrVY="
446
[mod."go.opentelemetry.io/proto/otlp"]
447
version = "v1.6.0"
448
hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg="
···
456
version = "v1.27.0"
457
hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU="
458
[mod."golang.org/x/crypto"]
459
+
version = "v0.40.0"
460
+
hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng="
461
[mod."golang.org/x/exp"]
462
+
version = "v0.0.0-20250620022241-b7579e27df2b"
463
+
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
464
[mod."golang.org/x/net"]
465
+
version = "v0.42.0"
466
+
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
467
[mod."golang.org/x/sync"]
468
+
version = "v0.16.0"
469
+
hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks="
470
[mod."golang.org/x/sys"]
471
+
version = "v0.34.0"
472
+
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
473
+
[mod."golang.org/x/text"]
474
+
version = "v0.27.0"
475
+
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
476
[mod."golang.org/x/time"]
477
+
version = "v0.12.0"
478
+
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
479
[mod."golang.org/x/xerrors"]
480
version = "v0.0.0-20240903120638-7835f813f4da"
481
hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo="
482
[mod."google.golang.org/genproto/googleapis/api"]
483
+
version = "v0.0.0-20250603155806-513f23925822"
484
+
hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU="
485
[mod."google.golang.org/genproto/googleapis/rpc"]
486
+
version = "v0.0.0-20250603155806-513f23925822"
487
hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM="
488
[mod."google.golang.org/grpc"]
489
+
version = "v1.73.0"
490
+
hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c="
491
[mod."google.golang.org/protobuf"]
492
version = "v1.36.6"
493
hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc="
···
510
version = "v1.4.1"
511
hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
512
[mod."tangled.sh/icyphox.sh/atproto-oauth"]
513
+
version = "v0.0.0-20250724194903-28e660378cb1"
514
+
hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+40
-35
nix/modules/appview.nix
+40
-35
nix/modules/appview.nix
···
1
-
{self}: {
2
config,
3
-
pkgs,
4
lib,
5
...
6
-
}:
7
-
with lib; {
8
-
options = {
9
-
services.tangled-appview = {
10
-
enable = mkOption {
11
-
type = types.bool;
12
-
default = false;
13
-
description = "Enable tangled appview";
14
-
};
15
-
port = mkOption {
16
-
type = types.int;
17
-
default = 3000;
18
-
description = "Port to run the appview on";
19
-
};
20
-
cookie_secret = mkOption {
21
-
type = types.str;
22
-
default = "00000000000000000000000000000000";
23
-
description = "Cookie secret";
24
};
25
};
26
-
};
27
28
-
config = mkIf config.services.tangled-appview.enable {
29
-
systemd.services.tangled-appview = {
30
-
description = "tangled appview service";
31
-
wantedBy = ["multi-user.target"];
32
33
-
serviceConfig = {
34
-
ListenStream = "0.0.0.0:${toString config.services.tangled-appview.port}";
35
-
ExecStart = "${self.packages.${pkgs.system}.appview}/bin/appview";
36
-
Restart = "always";
37
-
};
38
39
-
environment = {
40
-
TANGLED_DB_PATH = "appview.db";
41
-
TANGLED_COOKIE_SECRET = config.services.tangled-appview.cookie_secret;
42
};
43
};
44
-
};
45
-
}
···
1
+
{
2
config,
3
lib,
4
...
5
+
}: let
6
+
cfg = config.services.tangled-appview;
7
+
in
8
+
with lib; {
9
+
options = {
10
+
services.tangled-appview = {
11
+
enable = mkOption {
12
+
type = types.bool;
13
+
default = false;
14
+
description = "Enable tangled appview";
15
+
};
16
+
package = mkOption {
17
+
type = types.package;
18
+
description = "Package to use for the appview";
19
+
};
20
+
port = mkOption {
21
+
type = types.int;
22
+
default = 3000;
23
+
description = "Port to run the appview on";
24
+
};
25
+
cookie_secret = mkOption {
26
+
type = types.str;
27
+
default = "00000000000000000000000000000000";
28
+
description = "Cookie secret";
29
+
};
30
};
31
};
32
33
+
config = mkIf cfg.enable {
34
+
systemd.services.tangled-appview = {
35
+
description = "tangled appview service";
36
+
wantedBy = ["multi-user.target"];
37
38
+
serviceConfig = {
39
+
ListenStream = "0.0.0.0:${toString cfg.port}";
40
+
ExecStart = "${cfg.package}/bin/appview";
41
+
Restart = "always";
42
+
};
43
44
+
environment = {
45
+
TANGLED_DB_PATH = "appview.db";
46
+
TANGLED_COOKIE_SECRET = cfg.cookie_secret;
47
+
};
48
};
49
};
50
+
}
+45
-7
nix/modules/knot.nix
+45
-7
nix/modules/knot.nix
···
1
-
{self}: {
2
config,
3
pkgs,
4
lib,
···
13
type = types.bool;
14
default = false;
15
description = "Enable a tangled knot";
16
};
17
18
appviewEndpoint = mkOption {
···
53
};
54
};
55
56
server = {
57
listenAddr = mkOption {
58
type = types.str;
···
94
};
95
96
config = mkIf cfg.enable {
97
-
environment.systemPackages = with pkgs; [
98
-
git
99
-
self.packages."${pkgs.system}".knot
100
];
101
102
-
system.activationScripts.gitConfig = ''
103
mkdir -p "${cfg.repo.scanPath}"
104
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
105
···
108
[user]
109
name = Git User
110
email = git@example.com
111
EOF
112
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
113
'';
114
···
135
mode = "0555";
136
text = ''
137
#!${pkgs.stdenv.shell}
138
-
${self.packages.${pkgs.system}.knot}/bin/knot keys \
139
-output authorized-keys \
140
-internal-api "http://${cfg.server.internalListenAddr}" \
141
-git-dir "${cfg.repo.scanPath}" \
···
160
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
161
];
162
EnvironmentFile = cfg.server.secretFile;
163
-
ExecStart = "${self.packages.${pkgs.system}.knot}/bin/knot server";
164
Restart = "always";
165
};
166
};
···
1
+
{
2
config,
3
pkgs,
4
lib,
···
13
type = types.bool;
14
default = false;
15
description = "Enable a tangled knot";
16
+
};
17
+
18
+
package = mkOption {
19
+
type = types.package;
20
+
description = "Package to use for the knot";
21
};
22
23
appviewEndpoint = mkOption {
···
58
};
59
};
60
61
+
motd = mkOption {
62
+
type = types.nullOr types.str;
63
+
default = null;
64
+
description = ''
65
+
Message of the day
66
+
67
+
The contents are shown as-is; eg. you will want to add a newline if
68
+
setting a non-empty message since the knot won't do this for you.
69
+
'';
70
+
};
71
+
72
+
motdFile = mkOption {
73
+
type = types.nullOr types.path;
74
+
default = null;
75
+
description = ''
76
+
File containing message of the day
77
+
78
+
The contents are shown as-is; eg. you will want to add a newline if
79
+
setting a non-empty message since the knot won't do this for you.
80
+
'';
81
+
};
82
+
83
server = {
84
listenAddr = mkOption {
85
type = types.str;
···
121
};
122
123
config = mkIf cfg.enable {
124
+
environment.systemPackages = [
125
+
pkgs.git
126
+
cfg.package
127
];
128
129
+
system.activationScripts.gitConfig = let
130
+
setMotd =
131
+
if cfg.motdFile != null && cfg.motd != null
132
+
then throw "motdFile and motd cannot be both set"
133
+
else ''
134
+
${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"}
135
+
${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''}
136
+
'';
137
+
in ''
138
mkdir -p "${cfg.repo.scanPath}"
139
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
140
···
143
[user]
144
name = Git User
145
email = git@example.com
146
+
[receive]
147
+
advertisePushOptions = true
148
EOF
149
+
${setMotd}
150
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
151
'';
152
···
173
mode = "0555";
174
text = ''
175
#!${pkgs.stdenv.shell}
176
+
${cfg.package}/bin/knot keys \
177
-output authorized-keys \
178
-internal-api "http://${cfg.server.internalListenAddr}" \
179
-git-dir "${cfg.repo.scanPath}" \
···
198
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
199
];
200
EnvironmentFile = cfg.server.secretFile;
201
+
ExecStart = "${cfg.package}/bin/knot server";
202
Restart = "always";
203
};
204
};
+6
-3
nix/modules/spindle.nix
+6
-3
nix/modules/spindle.nix
···
1
-
{self}: {
2
config,
3
-
pkgs,
4
lib,
5
...
6
}: let
···
13
type = types.bool;
14
default = false;
15
description = "Enable a tangled spindle";
16
};
17
18
server = {
···
89
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
90
"SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
91
];
92
-
ExecStart = "${self.packages.${pkgs.system}.spindle}/bin/spindle";
93
Restart = "always";
94
};
95
};
···
1
+
{
2
config,
3
lib,
4
...
5
}: let
···
12
type = types.bool;
13
default = false;
14
description = "Enable a tangled spindle";
15
+
};
16
+
package = mkOption {
17
+
type = types.package;
18
+
description = "Package to use for the spindle";
19
};
20
21
server = {
···
92
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
93
"SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
94
];
95
+
ExecStart = "${cfg.package}/bin/spindle";
96
Restart = "always";
97
};
98
};
+1
nix/vm.nix
+1
nix/vm.nix
+4
rbac/rbac.go
+4
rbac/rbac.go
+23
-6
spindle/config/config.go
+23
-6
spindle/config/config.go
···
2
3
import (
4
"context"
5
6
"github.com/sethvargo/go-envconfig"
7
)
8
9
type Server struct {
10
-
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"`
11
-
DBPath string `env:"DB_PATH, default=spindle.db"`
12
-
Hostname string `env:"HOSTNAME, required"`
13
-
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
14
-
Dev bool `env:"DEV, default=false"`
15
-
Owner string `env:"OWNER, required"`
16
}
17
18
type Pipelines struct {
···
2
3
import (
4
"context"
5
+
"fmt"
6
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
"github.com/sethvargo/go-envconfig"
9
)
10
11
type Server struct {
12
+
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"`
13
+
DBPath string `env:"DB_PATH, default=spindle.db"`
14
+
Hostname string `env:"HOSTNAME, required"`
15
+
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
16
+
Dev bool `env:"DEV, default=false"`
17
+
Owner string `env:"OWNER, required"`
18
+
Secrets Secrets `env:",prefix=SECRETS_"`
19
+
}
20
+
21
+
func (s Server) Did() syntax.DID {
22
+
return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname))
23
+
}
24
+
25
+
type Secrets struct {
26
+
Provider string `env:"PROVIDER, default=sqlite"`
27
+
OpenBao OpenBaoConfig `env:",prefix=OPENBAO_"`
28
+
}
29
+
30
+
type OpenBaoConfig struct {
31
+
ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"`
32
+
Mount string `env:"MOUNT, default=spindle"`
33
}
34
35
type Pipelines struct {
+42
-19
spindle/engine/engine.go
+42
-19
spindle/engine/engine.go
···
11
"sync"
12
"time"
13
14
"github.com/docker/docker/api/types/container"
15
"github.com/docker/docker/api/types/image"
16
"github.com/docker/docker/api/types/mount"
···
18
"github.com/docker/docker/api/types/volume"
19
"github.com/docker/docker/client"
20
"github.com/docker/docker/pkg/stdcopy"
21
"tangled.sh/tangled.sh/core/log"
22
"tangled.sh/tangled.sh/core/notifier"
23
"tangled.sh/tangled.sh/core/spindle/config"
24
"tangled.sh/tangled.sh/core/spindle/db"
25
"tangled.sh/tangled.sh/core/spindle/models"
26
)
27
28
const (
···
37
db *db.DB
38
n *notifier.Notifier
39
cfg *config.Config
40
41
cleanupMu sync.Mutex
42
cleanup map[string][]cleanupFunc
43
}
44
45
-
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) {
46
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
47
if err != nil {
48
return nil, err
···
56
db: db,
57
n: n,
58
cfg: cfg,
59
}
60
61
e.cleanup = make(map[string][]cleanupFunc)
···
66
func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
67
e.l.Info("starting all workflows in parallel", "pipeline", pipelineId)
68
69
-
wg := sync.WaitGroup{}
70
for _, w := range pipeline.Workflows {
71
-
wg.Add(1)
72
-
go func() error {
73
-
defer wg.Done()
74
wid := models.WorkflowId{
75
PipelineId: pipelineId,
76
Name: w.Name,
···
102
defer reader.Close()
103
io.Copy(os.Stdout, reader)
104
105
-
workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout
106
-
workflowTimeout, err := time.ParseDuration(workflowTimeoutStr)
107
-
if err != nil {
108
-
e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr)
109
-
workflowTimeout = 5 * time.Minute
110
-
}
111
-
e.l.Info("using workflow timeout", "timeout", workflowTimeout)
112
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
113
defer cancel()
114
115
-
err = e.StartSteps(ctx, w.Steps, wid, w.Image)
116
if err != nil {
117
if errors.Is(err, ErrTimedOut) {
118
dbErr := e.db.StatusTimeout(wid, e.n)
···
135
}
136
137
return nil
138
-
}()
139
}
140
141
-
wg.Wait()
142
}
143
144
// SetupWorkflow sets up a new network for the workflow and volumes for
···
186
// ONLY marks pipeline as failed if container's exit code is non-zero.
187
// All other errors are bubbled up.
188
// Fixed version of the step execution logic
189
-
func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error {
190
191
-
for stepIdx, step := range steps {
192
select {
193
case <-ctx.Done():
194
return ctx.Err()
195
default:
196
}
197
198
-
envs := ConstructEnvs(step.Environment)
199
envs.AddEnv("HOME", workspaceDir)
200
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
201
202
hostConfig := hostConfig(wid)
203
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
204
-
Image: image,
205
Cmd: []string{"bash", "-c", step.Command},
206
WorkingDir: workspaceDir,
207
Tty: false,
···
11
"sync"
12
"time"
13
14
+
securejoin "github.com/cyphar/filepath-securejoin"
15
"github.com/docker/docker/api/types/container"
16
"github.com/docker/docker/api/types/image"
17
"github.com/docker/docker/api/types/mount"
···
19
"github.com/docker/docker/api/types/volume"
20
"github.com/docker/docker/client"
21
"github.com/docker/docker/pkg/stdcopy"
22
+
"golang.org/x/sync/errgroup"
23
"tangled.sh/tangled.sh/core/log"
24
"tangled.sh/tangled.sh/core/notifier"
25
"tangled.sh/tangled.sh/core/spindle/config"
26
"tangled.sh/tangled.sh/core/spindle/db"
27
"tangled.sh/tangled.sh/core/spindle/models"
28
+
"tangled.sh/tangled.sh/core/spindle/secrets"
29
)
30
31
const (
···
40
db *db.DB
41
n *notifier.Notifier
42
cfg *config.Config
43
+
vault secrets.Manager
44
45
cleanupMu sync.Mutex
46
cleanup map[string][]cleanupFunc
47
}
48
49
+
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) {
50
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
51
if err != nil {
52
return nil, err
···
60
db: db,
61
n: n,
62
cfg: cfg,
63
+
vault: vault,
64
}
65
66
e.cleanup = make(map[string][]cleanupFunc)
···
71
func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
72
e.l.Info("starting all workflows in parallel", "pipeline", pipelineId)
73
74
+
// extract secrets
75
+
var allSecrets []secrets.UnlockedSecret
76
+
if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil {
77
+
if res, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil {
78
+
allSecrets = res
79
+
}
80
+
}
81
+
82
+
workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout
83
+
workflowTimeout, err := time.ParseDuration(workflowTimeoutStr)
84
+
if err != nil {
85
+
e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr)
86
+
workflowTimeout = 5 * time.Minute
87
+
}
88
+
e.l.Info("using workflow timeout", "timeout", workflowTimeout)
89
+
90
+
eg, ctx := errgroup.WithContext(ctx)
91
for _, w := range pipeline.Workflows {
92
+
eg.Go(func() error {
93
wid := models.WorkflowId{
94
PipelineId: pipelineId,
95
Name: w.Name,
···
121
defer reader.Close()
122
io.Copy(os.Stdout, reader)
123
124
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
125
defer cancel()
126
127
+
err = e.StartSteps(ctx, wid, w, allSecrets)
128
if err != nil {
129
if errors.Is(err, ErrTimedOut) {
130
dbErr := e.db.StatusTimeout(wid, e.n)
···
147
}
148
149
return nil
150
+
})
151
}
152
153
+
if err = eg.Wait(); err != nil {
154
+
e.l.Error("failed to run one or more workflows", "err", err)
155
+
} else {
156
+
e.l.Error("successfully ran full pipeline")
157
+
}
158
}
159
160
// SetupWorkflow sets up a new network for the workflow and volumes for
···
202
// ONLY marks pipeline as failed if container's exit code is non-zero.
203
// All other errors are bubbled up.
204
// Fixed version of the step execution logic
205
+
func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error {
206
+
workflowEnvs := ConstructEnvs(w.Environment)
207
+
for _, s := range secrets {
208
+
workflowEnvs.AddEnv(s.Key, s.Value)
209
+
}
210
211
+
for stepIdx, step := range w.Steps {
212
select {
213
case <-ctx.Done():
214
return ctx.Err()
215
default:
216
}
217
218
+
envs := append(EnvVars(nil), workflowEnvs...)
219
+
for k, v := range step.Environment {
220
+
envs.AddEnv(k, v)
221
+
}
222
envs.AddEnv("HOME", workspaceDir)
223
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
224
225
hostConfig := hostConfig(wid)
226
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
227
+
Image: w.Image,
228
Cmd: []string{"bash", "-c", step.Command},
229
WorkingDir: workspaceDir,
230
Tty: false,
+129
-2
spindle/ingester.go
+129
-2
spindle/ingester.go
···
3
import (
4
"context"
5
"encoding/json"
6
"fmt"
7
8
"tangled.sh/tangled.sh/core/api/tangled"
9
"tangled.sh/tangled.sh/core/eventconsumer"
10
11
"github.com/bluesky-social/jetstream/pkg/models"
12
)
13
14
type Ingester func(ctx context.Context, e *models.Event) error
···
33
s.ingestMember(ctx, e)
34
case tangled.RepoNSID:
35
s.ingestRepo(ctx, e)
36
}
37
38
return err
···
72
return fmt.Errorf("failed to enforce permissions: %w", err)
73
}
74
75
-
if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil {
76
l.Error("failed to add member", "error", err)
77
return fmt.Errorf("failed to add member: %w", err)
78
}
···
90
return nil
91
}
92
93
-
func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error {
94
var err error
95
96
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
97
···
127
return fmt.Errorf("failed to add repo: %w", err)
128
}
129
130
// add this knot to the event consumer
131
src := eventconsumer.NewKnotSource(record.Knot)
132
s.ks.AddSource(context.Background(), src)
···
136
}
137
return nil
138
}
···
3
import (
4
"context"
5
"encoding/json"
6
+
"errors"
7
"fmt"
8
9
"tangled.sh/tangled.sh/core/api/tangled"
10
"tangled.sh/tangled.sh/core/eventconsumer"
11
+
"tangled.sh/tangled.sh/core/idresolver"
12
+
"tangled.sh/tangled.sh/core/rbac"
13
14
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
+
"github.com/bluesky-social/indigo/atproto/identity"
16
+
"github.com/bluesky-social/indigo/atproto/syntax"
17
+
"github.com/bluesky-social/indigo/xrpc"
18
"github.com/bluesky-social/jetstream/pkg/models"
19
+
securejoin "github.com/cyphar/filepath-securejoin"
20
)
21
22
type Ingester func(ctx context.Context, e *models.Event) error
···
41
s.ingestMember(ctx, e)
42
case tangled.RepoNSID:
43
s.ingestRepo(ctx, e)
44
+
case tangled.RepoCollaboratorNSID:
45
+
s.ingestCollaborator(ctx, e)
46
}
47
48
return err
···
82
return fmt.Errorf("failed to enforce permissions: %w", err)
83
}
84
85
+
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
86
l.Error("failed to add member", "error", err)
87
return fmt.Errorf("failed to add member: %w", err)
88
}
···
100
return nil
101
}
102
103
+
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
104
var err error
105
+
did := e.Did
106
+
resolver := idresolver.DefaultResolver()
107
108
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
109
···
139
return fmt.Errorf("failed to add repo: %w", err)
140
}
141
142
+
didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name)
143
+
if err != nil {
144
+
return err
145
+
}
146
+
147
+
// add repo to rbac
148
+
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil {
149
+
l.Error("failed to add repo to enforcer", "error", err)
150
+
return fmt.Errorf("failed to add repo: %w", err)
151
+
}
152
+
153
+
// add collaborators to rbac
154
+
owner, err := resolver.ResolveIdent(ctx, did)
155
+
if err != nil || owner.Handle.IsInvalidHandle() {
156
+
return err
157
+
}
158
+
if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil {
159
+
return err
160
+
}
161
+
162
// add this knot to the event consumer
163
src := eventconsumer.NewKnotSource(record.Knot)
164
s.ks.AddSource(context.Background(), src)
···
168
}
169
return nil
170
}
171
+
172
+
func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error {
173
+
var err error
174
+
175
+
l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did)
176
+
177
+
l.Info("ingesting collaborator record")
178
+
179
+
switch e.Commit.Operation {
180
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
181
+
raw := e.Commit.Record
182
+
record := tangled.RepoCollaborator{}
183
+
err = json.Unmarshal(raw, &record)
184
+
if err != nil {
185
+
l.Error("invalid record", "error", err)
186
+
return err
187
+
}
188
+
189
+
resolver := idresolver.DefaultResolver()
190
+
191
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
192
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
193
+
return err
194
+
}
195
+
196
+
repoAt, err := syntax.ParseATURI(record.Repo)
197
+
if err != nil {
198
+
l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo)
199
+
return nil
200
+
}
201
+
202
+
// TODO: get rid of this entirely
203
+
// resolve this aturi to extract the repo record
204
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
205
+
if err != nil || owner.Handle.IsInvalidHandle() {
206
+
return fmt.Errorf("failed to resolve handle: %w", err)
207
+
}
208
+
209
+
xrpcc := xrpc.Client{
210
+
Host: owner.PDSEndpoint(),
211
+
}
212
+
213
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
214
+
if err != nil {
215
+
return err
216
+
}
217
+
218
+
repo := resp.Value.Val.(*tangled.Repo)
219
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
220
+
221
+
// check perms for this user
222
+
if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil {
223
+
return fmt.Errorf("insufficient permissions: %w", err)
224
+
}
225
+
226
+
// add collaborator to rbac
227
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
228
+
l.Error("failed to add repo to enforcer", "error", err)
229
+
return fmt.Errorf("failed to add repo: %w", err)
230
+
}
231
+
232
+
return nil
233
+
}
234
+
return nil
235
+
}
236
+
237
+
func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error {
238
+
l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators")
239
+
240
+
l.Info("fetching and adding existing collaborators")
241
+
242
+
xrpcc := xrpc.Client{
243
+
Host: owner.PDSEndpoint(),
244
+
}
245
+
246
+
resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false)
247
+
if err != nil {
248
+
return err
249
+
}
250
+
251
+
var errs error
252
+
for _, r := range resp.Records {
253
+
if r == nil {
254
+
continue
255
+
}
256
+
record := r.Value.Val.(*tangled.RepoCollaborator)
257
+
258
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
259
+
l.Error("failed to add repo to enforcer", "error", err)
260
+
errors.Join(errs, fmt.Errorf("failed to add repo: %w", err))
261
+
}
262
+
}
263
+
264
+
return errs
265
+
}
+9
-12
spindle/models/pipeline.go
+9
-12
spindle/models/pipeline.go
···
8
)
9
10
type Pipeline struct {
11
Workflows []Workflow
12
}
13
···
63
swf.Environment = workflowEnvToMap(twf.Environment)
64
swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery)
65
66
-
swf.addNixProfileToPath()
67
-
swf.setGlobalEnvs()
68
setup := &setupSteps{}
69
70
setup.addStep(nixConfStep())
···
79
80
workflows = append(workflows, *swf)
81
}
82
-
return &Pipeline{Workflows: workflows}
83
}
84
85
func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
···
115
116
return path.Join(nixery, dependencies)
117
}
118
-
119
-
func (wf *Workflow) addNixProfileToPath() {
120
-
wf.Environment["PATH"] = "$PATH:/.nix-profile/bin"
121
-
}
122
-
123
-
func (wf *Workflow) setGlobalEnvs() {
124
-
wf.Environment["NIX_CONFIG"] = "experimental-features = nix-command flakes"
125
-
wf.Environment["HOME"] = "/tangled/workspace"
126
-
}
···
8
)
9
10
type Pipeline struct {
11
+
RepoOwner string
12
+
RepoName string
13
Workflows []Workflow
14
}
15
···
65
swf.Environment = workflowEnvToMap(twf.Environment)
66
swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery)
67
68
setup := &setupSteps{}
69
70
setup.addStep(nixConfStep())
···
79
80
workflows = append(workflows, *swf)
81
}
82
+
repoOwner := pl.TriggerMetadata.Repo.Did
83
+
repoName := pl.TriggerMetadata.Repo.Repo
84
+
return &Pipeline{
85
+
RepoOwner: repoOwner,
86
+
RepoName: repoName,
87
+
Workflows: workflows,
88
+
}
89
}
90
91
func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
···
121
122
return path.Join(nixery, dependencies)
123
}
+3
spindle/models/setup_steps.go
+3
spindle/models/setup_steps.go
+25
spindle/motd
+25
spindle/motd
···
···
1
+
****
2
+
*** ***
3
+
*** ** ****** **
4
+
** * *****
5
+
* ** **
6
+
* * * ***************
7
+
** ** *# **
8
+
* ** ** *** **
9
+
* * ** ** * ******
10
+
* ** ** * ** * *
11
+
** ** *** ** ** *
12
+
** ** * ** * *
13
+
** **** ** * *
14
+
** *** ** ** **
15
+
*** ** *****
16
+
********************
17
+
**
18
+
*
19
+
#**************
20
+
**
21
+
********
22
+
23
+
This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle
24
+
25
+
Most API routes are under /xrpc/
+70
spindle/secrets/manager.go
+70
spindle/secrets/manager.go
···
···
1
+
package secrets
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"regexp"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
)
11
+
12
+
type DidSlashRepo string
13
+
14
+
type Secret[T any] struct {
15
+
Key string
16
+
Value T
17
+
Repo DidSlashRepo
18
+
CreatedAt time.Time
19
+
CreatedBy syntax.DID
20
+
}
21
+
22
+
// the secret is not present
23
+
type LockedSecret = Secret[struct{}]
24
+
25
+
// the secret is present in plaintext, never expose this publicly,
26
+
// only use in the workflow engine
27
+
type UnlockedSecret = Secret[string]
28
+
29
+
type Manager interface {
30
+
AddSecret(ctx context.Context, secret UnlockedSecret) error
31
+
RemoveSecret(ctx context.Context, secret Secret[any]) error
32
+
GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error)
33
+
GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error)
34
+
}
35
+
36
+
// stopper interface for managers that need cleanup
37
+
type Stopper interface {
38
+
Stop()
39
+
}
40
+
41
+
var ErrKeyAlreadyPresent = errors.New("key already present")
42
+
var ErrInvalidKeyIdent = errors.New("key is not a valid identifier")
43
+
var ErrKeyNotFound = errors.New("key not found")
44
+
45
+
// ensure that we are satisfying the interface
46
+
var (
47
+
_ = []Manager{
48
+
&SqliteManager{},
49
+
&OpenBaoManager{},
50
+
}
51
+
)
52
+
53
+
var (
54
+
// bash identifier syntax
55
+
keyIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
56
+
)
57
+
58
+
func isValidKey(key string) bool {
59
+
if key == "" {
60
+
return false
61
+
}
62
+
return keyIdent.MatchString(key)
63
+
}
64
+
65
+
func ValidateKey(key string) error {
66
+
if !isValidKey(key) {
67
+
return ErrInvalidKeyIdent
68
+
}
69
+
return nil
70
+
}
+313
spindle/secrets/openbao.go
+313
spindle/secrets/openbao.go
···
···
1
+
package secrets
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"log/slog"
7
+
"path"
8
+
"strings"
9
+
"time"
10
+
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
vault "github.com/openbao/openbao/api/v2"
13
+
)
14
+
15
+
type OpenBaoManager struct {
16
+
client *vault.Client
17
+
mountPath string
18
+
logger *slog.Logger
19
+
}
20
+
21
+
type OpenBaoManagerOpt func(*OpenBaoManager)
22
+
23
+
func WithMountPath(mountPath string) OpenBaoManagerOpt {
24
+
return func(v *OpenBaoManager) {
25
+
v.mountPath = mountPath
26
+
}
27
+
}
28
+
29
+
// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy
30
+
// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200")
31
+
// The proxy handles all authentication automatically via Auto-Auth
32
+
func NewOpenBaoManager(proxyAddress string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) {
33
+
if proxyAddress == "" {
34
+
return nil, fmt.Errorf("proxy address cannot be empty")
35
+
}
36
+
37
+
config := vault.DefaultConfig()
38
+
config.Address = proxyAddress
39
+
40
+
client, err := vault.NewClient(config)
41
+
if err != nil {
42
+
return nil, fmt.Errorf("failed to create openbao client: %w", err)
43
+
}
44
+
45
+
manager := &OpenBaoManager{
46
+
client: client,
47
+
mountPath: "spindle", // default KV v2 mount path
48
+
logger: logger,
49
+
}
50
+
51
+
for _, opt := range opts {
52
+
opt(manager)
53
+
}
54
+
55
+
if err := manager.testConnection(); err != nil {
56
+
return nil, fmt.Errorf("failed to connect to bao proxy: %w", err)
57
+
}
58
+
59
+
logger.Info("successfully connected to bao proxy", "address", proxyAddress)
60
+
return manager, nil
61
+
}
62
+
63
+
// testConnection verifies that we can connect to the proxy
64
+
func (v *OpenBaoManager) testConnection() error {
65
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
66
+
defer cancel()
67
+
68
+
// try token self-lookup as a quick way to verify proxy works
69
+
// and is authenticated
70
+
_, err := v.client.Auth().Token().LookupSelfWithContext(ctx)
71
+
if err != nil {
72
+
return fmt.Errorf("proxy connection test failed: %w", err)
73
+
}
74
+
75
+
return nil
76
+
}
77
+
78
+
func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
79
+
if err := ValidateKey(secret.Key); err != nil {
80
+
return err
81
+
}
82
+
83
+
secretPath := v.buildSecretPath(secret.Repo, secret.Key)
84
+
v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath)
85
+
86
+
// Check if secret already exists
87
+
existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
88
+
if err == nil && existing != nil {
89
+
v.logger.Debug("secret already exists", "path", secretPath)
90
+
return ErrKeyAlreadyPresent
91
+
}
92
+
93
+
secretData := map[string]interface{}{
94
+
"value": secret.Value,
95
+
"repo": string(secret.Repo),
96
+
"key": secret.Key,
97
+
"created_at": secret.CreatedAt.Format(time.RFC3339),
98
+
"created_by": secret.CreatedBy.String(),
99
+
}
100
+
101
+
v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath)
102
+
resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData)
103
+
if err != nil {
104
+
v.logger.Error("failed to write secret", "path", secretPath, "error", err)
105
+
return fmt.Errorf("failed to store secret in openbao: %w", err)
106
+
}
107
+
108
+
v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime)
109
+
110
+
v.logger.Debug("verifying secret was written", "path", secretPath)
111
+
readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
112
+
if err != nil {
113
+
v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err)
114
+
return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err)
115
+
}
116
+
117
+
if readBack == nil || readBack.Data == nil {
118
+
v.logger.Error("secret verification returned empty data", "path", secretPath)
119
+
return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath)
120
+
}
121
+
122
+
v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version)
123
+
return nil
124
+
}
125
+
126
+
func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
127
+
secretPath := v.buildSecretPath(secret.Repo, secret.Key)
128
+
129
+
// check if secret exists
130
+
existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
131
+
if err != nil || existing == nil {
132
+
return ErrKeyNotFound
133
+
}
134
+
135
+
err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath)
136
+
if err != nil {
137
+
return fmt.Errorf("failed to delete secret from openbao: %w", err)
138
+
}
139
+
140
+
v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key)
141
+
return nil
142
+
}
143
+
144
+
func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) {
145
+
repoPath := v.buildRepoPath(repo)
146
+
147
+
secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
148
+
if err != nil {
149
+
if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
150
+
return []LockedSecret{}, nil
151
+
}
152
+
return nil, fmt.Errorf("failed to list secrets: %w", err)
153
+
}
154
+
155
+
if secretsList == nil || secretsList.Data == nil {
156
+
return []LockedSecret{}, nil
157
+
}
158
+
159
+
keys, ok := secretsList.Data["keys"].([]interface{})
160
+
if !ok {
161
+
return []LockedSecret{}, nil
162
+
}
163
+
164
+
var secrets []LockedSecret
165
+
166
+
for _, keyInterface := range keys {
167
+
key, ok := keyInterface.(string)
168
+
if !ok {
169
+
continue
170
+
}
171
+
172
+
secretPath := fmt.Sprintf("%s/%s", repoPath, key)
173
+
secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
174
+
if err != nil {
175
+
v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err)
176
+
continue
177
+
}
178
+
179
+
if secretData == nil || secretData.Data == nil {
180
+
continue
181
+
}
182
+
183
+
data := secretData.Data
184
+
185
+
createdAtStr, ok := data["created_at"].(string)
186
+
if !ok {
187
+
createdAtStr = time.Now().Format(time.RFC3339)
188
+
}
189
+
190
+
createdAt, err := time.Parse(time.RFC3339, createdAtStr)
191
+
if err != nil {
192
+
createdAt = time.Now()
193
+
}
194
+
195
+
createdByStr, ok := data["created_by"].(string)
196
+
if !ok {
197
+
createdByStr = ""
198
+
}
199
+
200
+
keyStr, ok := data["key"].(string)
201
+
if !ok {
202
+
keyStr = key
203
+
}
204
+
205
+
secret := LockedSecret{
206
+
Key: keyStr,
207
+
Repo: repo,
208
+
CreatedAt: createdAt,
209
+
CreatedBy: syntax.DID(createdByStr),
210
+
}
211
+
212
+
secrets = append(secrets, secret)
213
+
}
214
+
215
+
v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets))
216
+
return secrets, nil
217
+
}
218
+
219
+
func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) {
220
+
repoPath := v.buildRepoPath(repo)
221
+
222
+
secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
223
+
if err != nil {
224
+
if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
225
+
return []UnlockedSecret{}, nil
226
+
}
227
+
return nil, fmt.Errorf("failed to list secrets: %w", err)
228
+
}
229
+
230
+
if secretsList == nil || secretsList.Data == nil {
231
+
return []UnlockedSecret{}, nil
232
+
}
233
+
234
+
keys, ok := secretsList.Data["keys"].([]interface{})
235
+
if !ok {
236
+
return []UnlockedSecret{}, nil
237
+
}
238
+
239
+
var secrets []UnlockedSecret
240
+
241
+
for _, keyInterface := range keys {
242
+
key, ok := keyInterface.(string)
243
+
if !ok {
244
+
continue
245
+
}
246
+
247
+
secretPath := fmt.Sprintf("%s/%s", repoPath, key)
248
+
secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
249
+
if err != nil {
250
+
v.logger.Warn("failed to read secret", "path", secretPath, "error", err)
251
+
continue
252
+
}
253
+
254
+
if secretData == nil || secretData.Data == nil {
255
+
continue
256
+
}
257
+
258
+
data := secretData.Data
259
+
260
+
valueStr, ok := data["value"].(string)
261
+
if !ok {
262
+
v.logger.Warn("secret missing value", "path", secretPath)
263
+
continue
264
+
}
265
+
266
+
createdAtStr, ok := data["created_at"].(string)
267
+
if !ok {
268
+
createdAtStr = time.Now().Format(time.RFC3339)
269
+
}
270
+
271
+
createdAt, err := time.Parse(time.RFC3339, createdAtStr)
272
+
if err != nil {
273
+
createdAt = time.Now()
274
+
}
275
+
276
+
createdByStr, ok := data["created_by"].(string)
277
+
if !ok {
278
+
createdByStr = ""
279
+
}
280
+
281
+
keyStr, ok := data["key"].(string)
282
+
if !ok {
283
+
keyStr = key
284
+
}
285
+
286
+
secret := UnlockedSecret{
287
+
Key: keyStr,
288
+
Value: valueStr,
289
+
Repo: repo,
290
+
CreatedAt: createdAt,
291
+
CreatedBy: syntax.DID(createdByStr),
292
+
}
293
+
294
+
secrets = append(secrets, secret)
295
+
}
296
+
297
+
v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets))
298
+
return secrets, nil
299
+
}
300
+
301
+
// buildRepoPath creates a safe path for a repository
302
+
func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string {
303
+
// convert DidSlashRepo to a safe path by replacing special characters
304
+
repoPath := strings.ReplaceAll(string(repo), "/", "_")
305
+
repoPath = strings.ReplaceAll(repoPath, ":", "_")
306
+
repoPath = strings.ReplaceAll(repoPath, ".", "_")
307
+
return fmt.Sprintf("repos/%s", repoPath)
308
+
}
309
+
310
+
// buildSecretPath creates a path for a specific secret
311
+
func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string {
312
+
return path.Join(v.buildRepoPath(repo), key)
313
+
}
+605
spindle/secrets/openbao_test.go
+605
spindle/secrets/openbao_test.go
···
···
1
+
package secrets
2
+
3
+
import (
4
+
"context"
5
+
"log/slog"
6
+
"os"
7
+
"testing"
8
+
"time"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"github.com/stretchr/testify/assert"
12
+
)
13
+
14
+
// MockOpenBaoManager is a mock implementation of Manager interface for testing
15
+
type MockOpenBaoManager struct {
16
+
secrets map[string]UnlockedSecret // key: repo_key format
17
+
shouldError bool
18
+
errorToReturn error
19
+
}
20
+
21
+
func NewMockOpenBaoManager() *MockOpenBaoManager {
22
+
return &MockOpenBaoManager{secrets: make(map[string]UnlockedSecret)}
23
+
}
24
+
25
+
func (m *MockOpenBaoManager) SetError(err error) {
26
+
m.shouldError = true
27
+
m.errorToReturn = err
28
+
}
29
+
30
+
func (m *MockOpenBaoManager) ClearError() {
31
+
m.shouldError = false
32
+
m.errorToReturn = nil
33
+
}
34
+
35
+
func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string {
36
+
return string(repo) + "_" + key
37
+
}
38
+
39
+
func (m *MockOpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
40
+
if m.shouldError {
41
+
return m.errorToReturn
42
+
}
43
+
44
+
key := m.buildKey(secret.Repo, secret.Key)
45
+
if _, exists := m.secrets[key]; exists {
46
+
return ErrKeyAlreadyPresent
47
+
}
48
+
49
+
m.secrets[key] = secret
50
+
return nil
51
+
}
52
+
53
+
func (m *MockOpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
54
+
if m.shouldError {
55
+
return m.errorToReturn
56
+
}
57
+
58
+
key := m.buildKey(secret.Repo, secret.Key)
59
+
if _, exists := m.secrets[key]; !exists {
60
+
return ErrKeyNotFound
61
+
}
62
+
63
+
delete(m.secrets, key)
64
+
return nil
65
+
}
66
+
67
+
func (m *MockOpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) {
68
+
if m.shouldError {
69
+
return nil, m.errorToReturn
70
+
}
71
+
72
+
var result []LockedSecret
73
+
for _, secret := range m.secrets {
74
+
if secret.Repo == repo {
75
+
result = append(result, LockedSecret{
76
+
Key: secret.Key,
77
+
Repo: secret.Repo,
78
+
CreatedAt: secret.CreatedAt,
79
+
CreatedBy: secret.CreatedBy,
80
+
})
81
+
}
82
+
}
83
+
84
+
return result, nil
85
+
}
86
+
87
+
func (m *MockOpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) {
88
+
if m.shouldError {
89
+
return nil, m.errorToReturn
90
+
}
91
+
92
+
var result []UnlockedSecret
93
+
for _, secret := range m.secrets {
94
+
if secret.Repo == repo {
95
+
result = append(result, secret)
96
+
}
97
+
}
98
+
99
+
return result, nil
100
+
}
101
+
102
+
func createTestSecretForOpenBao(repo, key, value, createdBy string) UnlockedSecret {
103
+
return UnlockedSecret{
104
+
Key: key,
105
+
Value: value,
106
+
Repo: DidSlashRepo(repo),
107
+
CreatedAt: time.Now(),
108
+
CreatedBy: syntax.DID(createdBy),
109
+
}
110
+
}
111
+
112
+
// Test MockOpenBaoManager interface compliance
113
+
func TestMockOpenBaoManagerInterface(t *testing.T) {
114
+
var _ Manager = (*MockOpenBaoManager)(nil)
115
+
}
116
+
117
+
func TestOpenBaoManagerInterface(t *testing.T) {
118
+
var _ Manager = (*OpenBaoManager)(nil)
119
+
}
120
+
121
+
func TestNewOpenBaoManager(t *testing.T) {
122
+
tests := []struct {
123
+
name string
124
+
proxyAddr string
125
+
opts []OpenBaoManagerOpt
126
+
expectError bool
127
+
errorContains string
128
+
}{
129
+
{
130
+
name: "empty proxy address",
131
+
proxyAddr: "",
132
+
opts: nil,
133
+
expectError: true,
134
+
errorContains: "proxy address cannot be empty",
135
+
},
136
+
{
137
+
name: "valid proxy address",
138
+
proxyAddr: "http://localhost:8200",
139
+
opts: nil,
140
+
expectError: true, // Will fail because no real proxy is running
141
+
errorContains: "failed to connect to bao proxy",
142
+
},
143
+
{
144
+
name: "with mount path option",
145
+
proxyAddr: "http://localhost:8200",
146
+
opts: []OpenBaoManagerOpt{WithMountPath("custom-mount")},
147
+
expectError: true, // Will fail because no real proxy is running
148
+
errorContains: "failed to connect to bao proxy",
149
+
},
150
+
}
151
+
152
+
for _, tt := range tests {
153
+
t.Run(tt.name, func(t *testing.T) {
154
+
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
155
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...)
156
+
157
+
if tt.expectError {
158
+
assert.Error(t, err)
159
+
assert.Nil(t, manager)
160
+
assert.Contains(t, err.Error(), tt.errorContains)
161
+
} else {
162
+
assert.NoError(t, err)
163
+
assert.NotNil(t, manager)
164
+
}
165
+
})
166
+
}
167
+
}
168
+
169
+
func TestOpenBaoManager_PathBuilding(t *testing.T) {
170
+
manager := &OpenBaoManager{mountPath: "secret"}
171
+
172
+
tests := []struct {
173
+
name string
174
+
repo DidSlashRepo
175
+
key string
176
+
expected string
177
+
}{
178
+
{
179
+
name: "simple repo path",
180
+
repo: DidSlashRepo("did:plc:foo/repo"),
181
+
key: "api_key",
182
+
expected: "repos/did_plc_foo_repo/api_key",
183
+
},
184
+
{
185
+
name: "complex repo path with dots",
186
+
repo: DidSlashRepo("did:web:example.com/my-repo"),
187
+
key: "secret_key",
188
+
expected: "repos/did_web_example_com_my-repo/secret_key",
189
+
},
190
+
}
191
+
192
+
for _, tt := range tests {
193
+
t.Run(tt.name, func(t *testing.T) {
194
+
result := manager.buildSecretPath(tt.repo, tt.key)
195
+
assert.Equal(t, tt.expected, result)
196
+
})
197
+
}
198
+
}
199
+
200
+
func TestOpenBaoManager_buildRepoPath(t *testing.T) {
201
+
manager := &OpenBaoManager{mountPath: "test"}
202
+
203
+
tests := []struct {
204
+
name string
205
+
repo DidSlashRepo
206
+
expected string
207
+
}{
208
+
{
209
+
name: "simple repo",
210
+
repo: "did:plc:test/myrepo",
211
+
expected: "repos/did_plc_test_myrepo",
212
+
},
213
+
{
214
+
name: "repo with dots",
215
+
repo: "did:plc:example.com/my.repo",
216
+
expected: "repos/did_plc_example_com_my_repo",
217
+
},
218
+
{
219
+
name: "complex repo",
220
+
repo: "did:web:example.com:8080/path/to/repo",
221
+
expected: "repos/did_web_example_com_8080_path_to_repo",
222
+
},
223
+
}
224
+
225
+
for _, tt := range tests {
226
+
t.Run(tt.name, func(t *testing.T) {
227
+
result := manager.buildRepoPath(tt.repo)
228
+
assert.Equal(t, tt.expected, result)
229
+
})
230
+
}
231
+
}
232
+
233
+
func TestWithMountPath(t *testing.T) {
234
+
manager := &OpenBaoManager{mountPath: "default"}
235
+
236
+
opt := WithMountPath("custom-mount")
237
+
opt(manager)
238
+
239
+
assert.Equal(t, "custom-mount", manager.mountPath)
240
+
}
241
+
242
+
func TestMockOpenBaoManager_AddSecret(t *testing.T) {
243
+
tests := []struct {
244
+
name string
245
+
secrets []UnlockedSecret
246
+
expectError bool
247
+
}{
248
+
{
249
+
name: "add single secret",
250
+
secrets: []UnlockedSecret{
251
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
252
+
},
253
+
expectError: false,
254
+
},
255
+
{
256
+
name: "add multiple secrets",
257
+
secrets: []UnlockedSecret{
258
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
259
+
createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"),
260
+
},
261
+
expectError: false,
262
+
},
263
+
{
264
+
name: "add duplicate secret",
265
+
secrets: []UnlockedSecret{
266
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
267
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "newsecret", "did:plc:creator"),
268
+
},
269
+
expectError: true,
270
+
},
271
+
}
272
+
273
+
for _, tt := range tests {
274
+
t.Run(tt.name, func(t *testing.T) {
275
+
mock := NewMockOpenBaoManager()
276
+
ctx := context.Background()
277
+
var err error
278
+
279
+
for i, secret := range tt.secrets {
280
+
err = mock.AddSecret(ctx, secret)
281
+
if tt.expectError && i == 1 { // Second secret should fail for duplicate test
282
+
assert.Equal(t, ErrKeyAlreadyPresent, err)
283
+
return
284
+
}
285
+
if !tt.expectError {
286
+
assert.NoError(t, err)
287
+
}
288
+
}
289
+
290
+
if !tt.expectError {
291
+
assert.NoError(t, err)
292
+
}
293
+
})
294
+
}
295
+
}
296
+
297
+
func TestMockOpenBaoManager_RemoveSecret(t *testing.T) {
298
+
tests := []struct {
299
+
name string
300
+
setupSecrets []UnlockedSecret
301
+
removeSecret Secret[any]
302
+
expectError bool
303
+
}{
304
+
{
305
+
name: "remove existing secret",
306
+
setupSecrets: []UnlockedSecret{
307
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
308
+
},
309
+
removeSecret: Secret[any]{
310
+
Key: "API_KEY",
311
+
Repo: DidSlashRepo("did:plc:test/repo1"),
312
+
},
313
+
expectError: false,
314
+
},
315
+
{
316
+
name: "remove non-existent secret",
317
+
setupSecrets: []UnlockedSecret{},
318
+
removeSecret: Secret[any]{
319
+
Key: "API_KEY",
320
+
Repo: DidSlashRepo("did:plc:test/repo1"),
321
+
},
322
+
expectError: true,
323
+
},
324
+
}
325
+
326
+
for _, tt := range tests {
327
+
t.Run(tt.name, func(t *testing.T) {
328
+
mock := NewMockOpenBaoManager()
329
+
ctx := context.Background()
330
+
331
+
// Setup secrets
332
+
for _, secret := range tt.setupSecrets {
333
+
err := mock.AddSecret(ctx, secret)
334
+
assert.NoError(t, err)
335
+
}
336
+
337
+
// Remove secret
338
+
err := mock.RemoveSecret(ctx, tt.removeSecret)
339
+
340
+
if tt.expectError {
341
+
assert.Equal(t, ErrKeyNotFound, err)
342
+
} else {
343
+
assert.NoError(t, err)
344
+
}
345
+
})
346
+
}
347
+
}
348
+
349
+
func TestMockOpenBaoManager_GetSecretsLocked(t *testing.T) {
350
+
tests := []struct {
351
+
name string
352
+
setupSecrets []UnlockedSecret
353
+
queryRepo DidSlashRepo
354
+
expectedCount int
355
+
expectedKeys []string
356
+
expectError bool
357
+
}{
358
+
{
359
+
name: "get secrets from repo with secrets",
360
+
setupSecrets: []UnlockedSecret{
361
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
362
+
createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"),
363
+
createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"),
364
+
},
365
+
queryRepo: DidSlashRepo("did:plc:test/repo1"),
366
+
expectedCount: 2,
367
+
expectedKeys: []string{"API_KEY", "DB_PASSWORD"},
368
+
expectError: false,
369
+
},
370
+
{
371
+
name: "get secrets from empty repo",
372
+
setupSecrets: []UnlockedSecret{},
373
+
queryRepo: DidSlashRepo("did:plc:test/empty"),
374
+
expectedCount: 0,
375
+
expectedKeys: []string{},
376
+
expectError: false,
377
+
},
378
+
}
379
+
380
+
for _, tt := range tests {
381
+
t.Run(tt.name, func(t *testing.T) {
382
+
mock := NewMockOpenBaoManager()
383
+
ctx := context.Background()
384
+
385
+
// Setup
386
+
for _, secret := range tt.setupSecrets {
387
+
err := mock.AddSecret(ctx, secret)
388
+
assert.NoError(t, err)
389
+
}
390
+
391
+
// Test
392
+
secrets, err := mock.GetSecretsLocked(ctx, tt.queryRepo)
393
+
394
+
if tt.expectError {
395
+
assert.Error(t, err)
396
+
} else {
397
+
assert.NoError(t, err)
398
+
assert.Len(t, secrets, tt.expectedCount)
399
+
400
+
// Check keys
401
+
actualKeys := make([]string, len(secrets))
402
+
for i, secret := range secrets {
403
+
actualKeys[i] = secret.Key
404
+
}
405
+
406
+
for _, expectedKey := range tt.expectedKeys {
407
+
assert.Contains(t, actualKeys, expectedKey)
408
+
}
409
+
}
410
+
})
411
+
}
412
+
}
413
+
414
+
func TestMockOpenBaoManager_GetSecretsUnlocked(t *testing.T) {
415
+
tests := []struct {
416
+
name string
417
+
setupSecrets []UnlockedSecret
418
+
queryRepo DidSlashRepo
419
+
expectedCount int
420
+
expectedSecrets map[string]string // key -> value
421
+
expectError bool
422
+
}{
423
+
{
424
+
name: "get unlocked secrets from repo",
425
+
setupSecrets: []UnlockedSecret{
426
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
427
+
createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"),
428
+
createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"),
429
+
},
430
+
queryRepo: DidSlashRepo("did:plc:test/repo1"),
431
+
expectedCount: 2,
432
+
expectedSecrets: map[string]string{
433
+
"API_KEY": "secret123",
434
+
"DB_PASSWORD": "dbpass456",
435
+
},
436
+
expectError: false,
437
+
},
438
+
{
439
+
name: "get secrets from empty repo",
440
+
setupSecrets: []UnlockedSecret{},
441
+
queryRepo: DidSlashRepo("did:plc:test/empty"),
442
+
expectedCount: 0,
443
+
expectedSecrets: map[string]string{},
444
+
expectError: false,
445
+
},
446
+
}
447
+
448
+
for _, tt := range tests {
449
+
t.Run(tt.name, func(t *testing.T) {
450
+
mock := NewMockOpenBaoManager()
451
+
ctx := context.Background()
452
+
453
+
// Setup
454
+
for _, secret := range tt.setupSecrets {
455
+
err := mock.AddSecret(ctx, secret)
456
+
assert.NoError(t, err)
457
+
}
458
+
459
+
// Test
460
+
secrets, err := mock.GetSecretsUnlocked(ctx, tt.queryRepo)
461
+
462
+
if tt.expectError {
463
+
assert.Error(t, err)
464
+
} else {
465
+
assert.NoError(t, err)
466
+
assert.Len(t, secrets, tt.expectedCount)
467
+
468
+
// Check key-value pairs
469
+
actualSecrets := make(map[string]string)
470
+
for _, secret := range secrets {
471
+
actualSecrets[secret.Key] = secret.Value
472
+
}
473
+
474
+
for expectedKey, expectedValue := range tt.expectedSecrets {
475
+
actualValue, exists := actualSecrets[expectedKey]
476
+
assert.True(t, exists, "Expected key %s not found", expectedKey)
477
+
assert.Equal(t, expectedValue, actualValue)
478
+
}
479
+
}
480
+
})
481
+
}
482
+
}
483
+
484
+
func TestMockOpenBaoManager_ErrorHandling(t *testing.T) {
485
+
mock := NewMockOpenBaoManager()
486
+
ctx := context.Background()
487
+
testError := assert.AnError
488
+
489
+
// Test error injection
490
+
mock.SetError(testError)
491
+
492
+
secret := createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator")
493
+
494
+
// All operations should return the injected error
495
+
err := mock.AddSecret(ctx, secret)
496
+
assert.Equal(t, testError, err)
497
+
498
+
_, err = mock.GetSecretsLocked(ctx, "did:plc:test/repo1")
499
+
assert.Equal(t, testError, err)
500
+
501
+
_, err = mock.GetSecretsUnlocked(ctx, "did:plc:test/repo1")
502
+
assert.Equal(t, testError, err)
503
+
504
+
err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: "did:plc:test/repo1"})
505
+
assert.Equal(t, testError, err)
506
+
507
+
// Clear error and test normal operation
508
+
mock.ClearError()
509
+
err = mock.AddSecret(ctx, secret)
510
+
assert.NoError(t, err)
511
+
}
512
+
513
+
func TestMockOpenBaoManager_Integration(t *testing.T) {
514
+
tests := []struct {
515
+
name string
516
+
scenario func(t *testing.T, mock *MockOpenBaoManager)
517
+
}{
518
+
{
519
+
name: "complete workflow",
520
+
scenario: func(t *testing.T, mock *MockOpenBaoManager) {
521
+
ctx := context.Background()
522
+
repo := DidSlashRepo("did:plc:test/integration")
523
+
524
+
// Start with empty repo
525
+
secrets, err := mock.GetSecretsLocked(ctx, repo)
526
+
assert.NoError(t, err)
527
+
assert.Empty(t, secrets)
528
+
529
+
// Add some secrets
530
+
secret1 := createTestSecretForOpenBao(string(repo), "API_KEY", "secret123", "did:plc:creator")
531
+
secret2 := createTestSecretForOpenBao(string(repo), "DB_PASSWORD", "dbpass456", "did:plc:creator")
532
+
533
+
err = mock.AddSecret(ctx, secret1)
534
+
assert.NoError(t, err)
535
+
536
+
err = mock.AddSecret(ctx, secret2)
537
+
assert.NoError(t, err)
538
+
539
+
// Verify secrets exist
540
+
secrets, err = mock.GetSecretsLocked(ctx, repo)
541
+
assert.NoError(t, err)
542
+
assert.Len(t, secrets, 2)
543
+
544
+
unlockedSecrets, err := mock.GetSecretsUnlocked(ctx, repo)
545
+
assert.NoError(t, err)
546
+
assert.Len(t, unlockedSecrets, 2)
547
+
548
+
// Remove one secret
549
+
err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: repo})
550
+
assert.NoError(t, err)
551
+
552
+
// Verify only one secret remains
553
+
secrets, err = mock.GetSecretsLocked(ctx, repo)
554
+
assert.NoError(t, err)
555
+
assert.Len(t, secrets, 1)
556
+
assert.Equal(t, "DB_PASSWORD", secrets[0].Key)
557
+
},
558
+
},
559
+
}
560
+
561
+
for _, tt := range tests {
562
+
t.Run(tt.name, func(t *testing.T) {
563
+
mock := NewMockOpenBaoManager()
564
+
tt.scenario(t, mock)
565
+
})
566
+
}
567
+
}
568
+
569
+
func TestOpenBaoManager_ProxyConfiguration(t *testing.T) {
570
+
tests := []struct {
571
+
name string
572
+
proxyAddr string
573
+
description string
574
+
}{
575
+
{
576
+
name: "default_localhost",
577
+
proxyAddr: "http://127.0.0.1:8200",
578
+
description: "Should connect to default localhost proxy",
579
+
},
580
+
{
581
+
name: "custom_host",
582
+
proxyAddr: "http://bao-proxy:8200",
583
+
description: "Should connect to custom proxy host",
584
+
},
585
+
{
586
+
name: "https_proxy",
587
+
proxyAddr: "https://127.0.0.1:8200",
588
+
description: "Should connect to HTTPS proxy",
589
+
},
590
+
}
591
+
592
+
for _, tt := range tests {
593
+
t.Run(tt.name, func(t *testing.T) {
594
+
t.Log("Testing scenario:", tt.description)
595
+
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
596
+
597
+
// All these will fail because no real proxy is running
598
+
// but we can test that the configuration is properly accepted
599
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger)
600
+
assert.Error(t, err) // Expected because no real proxy
601
+
assert.Nil(t, manager)
602
+
assert.Contains(t, err.Error(), "failed to connect to bao proxy")
603
+
})
604
+
}
605
+
}
+22
spindle/secrets/policy.hcl
+22
spindle/secrets/policy.hcl
···
···
1
+
# Allow full access to the spindle KV mount
2
+
path "spindle/*" {
3
+
capabilities = ["create", "read", "update", "delete", "list"]
4
+
}
5
+
6
+
path "spindle/data/*" {
7
+
capabilities = ["create", "read", "update", "delete"]
8
+
}
9
+
10
+
path "spindle/metadata/*" {
11
+
capabilities = ["list", "read", "delete"]
12
+
}
13
+
14
+
# Allow listing mounts (for connection testing)
15
+
path "sys/mounts" {
16
+
capabilities = ["read"]
17
+
}
18
+
19
+
# Allow token self-lookup (for health checks)
20
+
path "auth/token/lookup-self" {
21
+
capabilities = ["read"]
22
+
}
+172
spindle/secrets/sqlite.go
+172
spindle/secrets/sqlite.go
···
···
1
+
// an sqlite3 backed secret manager
2
+
package secrets
3
+
4
+
import (
5
+
"context"
6
+
"database/sql"
7
+
"fmt"
8
+
"time"
9
+
10
+
_ "github.com/mattn/go-sqlite3"
11
+
)
12
+
13
+
type SqliteManager struct {
14
+
db *sql.DB
15
+
tableName string
16
+
}
17
+
18
+
type SqliteManagerOpt func(*SqliteManager)
19
+
20
+
func WithTableName(name string) SqliteManagerOpt {
21
+
return func(s *SqliteManager) {
22
+
s.tableName = name
23
+
}
24
+
}
25
+
26
+
func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) {
27
+
db, err := sql.Open("sqlite3", dbPath)
28
+
if err != nil {
29
+
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
30
+
}
31
+
32
+
manager := &SqliteManager{
33
+
db: db,
34
+
tableName: "secrets",
35
+
}
36
+
37
+
for _, o := range opts {
38
+
o(manager)
39
+
}
40
+
41
+
if err := manager.init(); err != nil {
42
+
return nil, err
43
+
}
44
+
45
+
return manager, nil
46
+
}
47
+
48
+
// creates a table and sets up the schema, migrations if any can go here
49
+
func (s *SqliteManager) init() error {
50
+
createTable :=
51
+
`create table if not exists ` + s.tableName + `(
52
+
id integer primary key autoincrement,
53
+
repo text not null,
54
+
key text not null,
55
+
value text not null,
56
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
57
+
created_by text not null,
58
+
59
+
unique(repo, key)
60
+
);`
61
+
_, err := s.db.Exec(createTable)
62
+
return err
63
+
}
64
+
65
+
func (s *SqliteManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
66
+
query := fmt.Sprintf(`
67
+
insert or ignore into %s (repo, key, value, created_by)
68
+
values (?, ?, ?, ?);
69
+
`, s.tableName)
70
+
71
+
res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key, secret.Value, secret.CreatedBy)
72
+
if err != nil {
73
+
return err
74
+
}
75
+
76
+
num, err := res.RowsAffected()
77
+
if err != nil {
78
+
return err
79
+
}
80
+
81
+
if num == 0 {
82
+
return ErrKeyAlreadyPresent
83
+
}
84
+
85
+
return nil
86
+
}
87
+
88
+
func (s *SqliteManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
89
+
query := fmt.Sprintf(`
90
+
delete from %s where repo = ? and key = ?;
91
+
`, s.tableName)
92
+
93
+
res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key)
94
+
if err != nil {
95
+
return err
96
+
}
97
+
98
+
num, err := res.RowsAffected()
99
+
if err != nil {
100
+
return err
101
+
}
102
+
103
+
if num == 0 {
104
+
return ErrKeyNotFound
105
+
}
106
+
107
+
return nil
108
+
}
109
+
110
+
func (s *SqliteManager) GetSecretsLocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]LockedSecret, error) {
111
+
query := fmt.Sprintf(`
112
+
select repo, key, created_at, created_by from %s where repo = ?;
113
+
`, s.tableName)
114
+
115
+
rows, err := s.db.QueryContext(ctx, query, didSlashRepo)
116
+
if err != nil {
117
+
return nil, err
118
+
}
119
+
120
+
var ls []LockedSecret
121
+
for rows.Next() {
122
+
var l LockedSecret
123
+
var createdAt string
124
+
if err = rows.Scan(&l.Repo, &l.Key, &createdAt, &l.CreatedBy); err != nil {
125
+
return nil, err
126
+
}
127
+
128
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
129
+
l.CreatedAt = t
130
+
}
131
+
132
+
ls = append(ls, l)
133
+
}
134
+
135
+
if err = rows.Err(); err != nil {
136
+
return nil, err
137
+
}
138
+
139
+
return ls, nil
140
+
}
141
+
142
+
func (s *SqliteManager) GetSecretsUnlocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]UnlockedSecret, error) {
143
+
query := fmt.Sprintf(`
144
+
select repo, key, value, created_at, created_by from %s where repo = ?;
145
+
`, s.tableName)
146
+
147
+
rows, err := s.db.QueryContext(ctx, query, didSlashRepo)
148
+
if err != nil {
149
+
return nil, err
150
+
}
151
+
152
+
var ls []UnlockedSecret
153
+
for rows.Next() {
154
+
var l UnlockedSecret
155
+
var createdAt string
156
+
if err = rows.Scan(&l.Repo, &l.Key, &l.Value, &createdAt, &l.CreatedBy); err != nil {
157
+
return nil, err
158
+
}
159
+
160
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
161
+
l.CreatedAt = t
162
+
}
163
+
164
+
ls = append(ls, l)
165
+
}
166
+
167
+
if err = rows.Err(); err != nil {
168
+
return nil, err
169
+
}
170
+
171
+
return ls, nil
172
+
}
+590
spindle/secrets/sqlite_test.go
+590
spindle/secrets/sqlite_test.go
···
···
1
+
package secrets
2
+
3
+
import (
4
+
"context"
5
+
"testing"
6
+
"time"
7
+
8
+
"github.com/alecthomas/assert/v2"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
)
11
+
12
+
func createInMemoryDB(t *testing.T) *SqliteManager {
13
+
t.Helper()
14
+
manager, err := NewSQLiteManager(":memory:")
15
+
if err != nil {
16
+
t.Fatalf("Failed to create in-memory manager: %v", err)
17
+
}
18
+
return manager
19
+
}
20
+
21
+
func createTestSecret(repo, key, value, createdBy string) UnlockedSecret {
22
+
return UnlockedSecret{
23
+
Key: key,
24
+
Value: value,
25
+
Repo: DidSlashRepo(repo),
26
+
CreatedAt: time.Now(),
27
+
CreatedBy: syntax.DID(createdBy),
28
+
}
29
+
}
30
+
31
+
// ensure that interface is satisfied
32
+
func TestManagerInterface(t *testing.T) {
33
+
var _ Manager = (*SqliteManager)(nil)
34
+
}
35
+
36
+
func TestNewSQLiteManager(t *testing.T) {
37
+
tests := []struct {
38
+
name string
39
+
dbPath string
40
+
opts []SqliteManagerOpt
41
+
expectError bool
42
+
expectTable string
43
+
}{
44
+
{
45
+
name: "default table name",
46
+
dbPath: ":memory:",
47
+
opts: nil,
48
+
expectError: false,
49
+
expectTable: "secrets",
50
+
},
51
+
{
52
+
name: "custom table name",
53
+
dbPath: ":memory:",
54
+
opts: []SqliteManagerOpt{WithTableName("custom_secrets")},
55
+
expectError: false,
56
+
expectTable: "custom_secrets",
57
+
},
58
+
{
59
+
name: "invalid database path",
60
+
dbPath: "/invalid/path/to/database.db",
61
+
opts: nil,
62
+
expectError: true,
63
+
expectTable: "",
64
+
},
65
+
}
66
+
67
+
for _, tt := range tests {
68
+
t.Run(tt.name, func(t *testing.T) {
69
+
manager, err := NewSQLiteManager(tt.dbPath, tt.opts...)
70
+
if tt.expectError {
71
+
if err == nil {
72
+
t.Error("Expected error but got none")
73
+
}
74
+
return
75
+
}
76
+
77
+
if err != nil {
78
+
t.Fatalf("Unexpected error: %v", err)
79
+
}
80
+
defer manager.db.Close()
81
+
82
+
if manager.tableName != tt.expectTable {
83
+
t.Errorf("Expected table name %s, got %s", tt.expectTable, manager.tableName)
84
+
}
85
+
})
86
+
}
87
+
}
88
+
89
+
func TestSqliteManager_AddSecret(t *testing.T) {
90
+
tests := []struct {
91
+
name string
92
+
secrets []UnlockedSecret
93
+
expectError []error
94
+
}{
95
+
{
96
+
name: "add single secret",
97
+
secrets: []UnlockedSecret{
98
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
99
+
},
100
+
expectError: []error{nil},
101
+
},
102
+
{
103
+
name: "add multiple unique secrets",
104
+
secrets: []UnlockedSecret{
105
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
106
+
createTestSecret("did:plc:foo/repo", "db_password", "password_456", "did:plc:example123"),
107
+
createTestSecret("other.com/repo", "api_key", "other_secret", "did:plc:other"),
108
+
},
109
+
expectError: []error{nil, nil, nil},
110
+
},
111
+
{
112
+
name: "add duplicate secret",
113
+
secrets: []UnlockedSecret{
114
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
115
+
createTestSecret("did:plc:foo/repo", "api_key", "different_value", "did:plc:example123"),
116
+
},
117
+
expectError: []error{nil, ErrKeyAlreadyPresent},
118
+
},
119
+
}
120
+
121
+
for _, tt := range tests {
122
+
t.Run(tt.name, func(t *testing.T) {
123
+
manager := createInMemoryDB(t)
124
+
defer manager.db.Close()
125
+
126
+
for i, secret := range tt.secrets {
127
+
err := manager.AddSecret(context.Background(), secret)
128
+
if err != tt.expectError[i] {
129
+
t.Errorf("Secret %d: expected error %v, got %v", i, tt.expectError[i], err)
130
+
}
131
+
}
132
+
})
133
+
}
134
+
}
135
+
136
+
func TestSqliteManager_RemoveSecret(t *testing.T) {
137
+
tests := []struct {
138
+
name string
139
+
setupSecrets []UnlockedSecret
140
+
removeSecret Secret[any]
141
+
expectError error
142
+
}{
143
+
{
144
+
name: "remove existing secret",
145
+
setupSecrets: []UnlockedSecret{
146
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
147
+
},
148
+
removeSecret: Secret[any]{
149
+
Key: "api_key",
150
+
Repo: DidSlashRepo("did:plc:foo/repo"),
151
+
},
152
+
expectError: nil,
153
+
},
154
+
{
155
+
name: "remove non-existent secret",
156
+
setupSecrets: []UnlockedSecret{
157
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
158
+
},
159
+
removeSecret: Secret[any]{
160
+
Key: "non_existent_key",
161
+
Repo: DidSlashRepo("did:plc:foo/repo"),
162
+
},
163
+
expectError: ErrKeyNotFound,
164
+
},
165
+
{
166
+
name: "remove from empty database",
167
+
setupSecrets: []UnlockedSecret{},
168
+
removeSecret: Secret[any]{
169
+
Key: "any_key",
170
+
Repo: DidSlashRepo("did:plc:foo/repo"),
171
+
},
172
+
expectError: ErrKeyNotFound,
173
+
},
174
+
{
175
+
name: "remove secret from wrong repo",
176
+
setupSecrets: []UnlockedSecret{
177
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
178
+
},
179
+
removeSecret: Secret[any]{
180
+
Key: "api_key",
181
+
Repo: DidSlashRepo("other.com/repo"),
182
+
},
183
+
expectError: ErrKeyNotFound,
184
+
},
185
+
}
186
+
187
+
for _, tt := range tests {
188
+
t.Run(tt.name, func(t *testing.T) {
189
+
manager := createInMemoryDB(t)
190
+
defer manager.db.Close()
191
+
192
+
// Setup secrets
193
+
for _, secret := range tt.setupSecrets {
194
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
195
+
t.Fatalf("Failed to setup secret: %v", err)
196
+
}
197
+
}
198
+
199
+
// Test removal
200
+
err := manager.RemoveSecret(context.Background(), tt.removeSecret)
201
+
if err != tt.expectError {
202
+
t.Errorf("Expected error %v, got %v", tt.expectError, err)
203
+
}
204
+
})
205
+
}
206
+
}
207
+
208
+
func TestSqliteManager_GetSecretsLocked(t *testing.T) {
209
+
tests := []struct {
210
+
name string
211
+
setupSecrets []UnlockedSecret
212
+
queryRepo DidSlashRepo
213
+
expectedCount int
214
+
expectedKeys []string
215
+
expectError bool
216
+
}{
217
+
{
218
+
name: "get secrets for repo with multiple secrets",
219
+
setupSecrets: []UnlockedSecret{
220
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
221
+
createTestSecret("did:plc:foo/repo", "key2", "value2", "did:plc:user2"),
222
+
createTestSecret("other.com/repo", "key3", "value3", "did:plc:user3"),
223
+
},
224
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
225
+
expectedCount: 2,
226
+
expectedKeys: []string{"key1", "key2"},
227
+
expectError: false,
228
+
},
229
+
{
230
+
name: "get secrets for repo with single secret",
231
+
setupSecrets: []UnlockedSecret{
232
+
createTestSecret("did:plc:foo/repo", "single_key", "single_value", "did:plc:user1"),
233
+
createTestSecret("other.com/repo", "other_key", "other_value", "did:plc:user2"),
234
+
},
235
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
236
+
expectedCount: 1,
237
+
expectedKeys: []string{"single_key"},
238
+
expectError: false,
239
+
},
240
+
{
241
+
name: "get secrets for non-existent repo",
242
+
setupSecrets: []UnlockedSecret{
243
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
244
+
},
245
+
queryRepo: DidSlashRepo("nonexistent.com/repo"),
246
+
expectedCount: 0,
247
+
expectedKeys: []string{},
248
+
expectError: false,
249
+
},
250
+
{
251
+
name: "get secrets from empty database",
252
+
setupSecrets: []UnlockedSecret{},
253
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
254
+
expectedCount: 0,
255
+
expectedKeys: []string{},
256
+
expectError: false,
257
+
},
258
+
}
259
+
260
+
for _, tt := range tests {
261
+
t.Run(tt.name, func(t *testing.T) {
262
+
manager := createInMemoryDB(t)
263
+
defer manager.db.Close()
264
+
265
+
// Setup secrets
266
+
for _, secret := range tt.setupSecrets {
267
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
268
+
t.Fatalf("Failed to setup secret: %v", err)
269
+
}
270
+
}
271
+
272
+
// Test getting locked secrets
273
+
lockedSecrets, err := manager.GetSecretsLocked(context.Background(), tt.queryRepo)
274
+
if tt.expectError && err == nil {
275
+
t.Error("Expected error but got none")
276
+
return
277
+
}
278
+
if !tt.expectError && err != nil {
279
+
t.Fatalf("Unexpected error: %v", err)
280
+
}
281
+
282
+
if len(lockedSecrets) != tt.expectedCount {
283
+
t.Errorf("Expected %d secrets, got %d", tt.expectedCount, len(lockedSecrets))
284
+
}
285
+
286
+
// Verify keys and that values are not present (locked)
287
+
foundKeys := make(map[string]bool)
288
+
for _, ls := range lockedSecrets {
289
+
foundKeys[ls.Key] = true
290
+
if ls.Repo != tt.queryRepo {
291
+
t.Errorf("Expected repo %s, got %s", tt.queryRepo, ls.Repo)
292
+
}
293
+
if ls.CreatedBy == "" {
294
+
t.Error("Expected CreatedBy to be present")
295
+
}
296
+
if ls.CreatedAt.IsZero() {
297
+
t.Error("Expected CreatedAt to be set")
298
+
}
299
+
}
300
+
301
+
for _, expectedKey := range tt.expectedKeys {
302
+
if !foundKeys[expectedKey] {
303
+
t.Errorf("Expected key %s not found", expectedKey)
304
+
}
305
+
}
306
+
})
307
+
}
308
+
}
309
+
310
+
func TestSqliteManager_GetSecretsUnlocked(t *testing.T) {
311
+
tests := []struct {
312
+
name string
313
+
setupSecrets []UnlockedSecret
314
+
queryRepo DidSlashRepo
315
+
expectedCount int
316
+
expectedSecrets map[string]string // key -> value
317
+
expectError bool
318
+
}{
319
+
{
320
+
name: "get unlocked secrets for repo with multiple secrets",
321
+
setupSecrets: []UnlockedSecret{
322
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
323
+
createTestSecret("did:plc:foo/repo", "key2", "value2", "did:plc:user2"),
324
+
createTestSecret("other.com/repo", "key3", "value3", "did:plc:user3"),
325
+
},
326
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
327
+
expectedCount: 2,
328
+
expectedSecrets: map[string]string{
329
+
"key1": "value1",
330
+
"key2": "value2",
331
+
},
332
+
expectError: false,
333
+
},
334
+
{
335
+
name: "get unlocked secrets for repo with single secret",
336
+
setupSecrets: []UnlockedSecret{
337
+
createTestSecret("did:plc:foo/repo", "single_key", "single_value", "did:plc:user1"),
338
+
createTestSecret("other.com/repo", "other_key", "other_value", "did:plc:user2"),
339
+
},
340
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
341
+
expectedCount: 1,
342
+
expectedSecrets: map[string]string{
343
+
"single_key": "single_value",
344
+
},
345
+
expectError: false,
346
+
},
347
+
{
348
+
name: "get unlocked secrets for non-existent repo",
349
+
setupSecrets: []UnlockedSecret{
350
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
351
+
},
352
+
queryRepo: DidSlashRepo("nonexistent.com/repo"),
353
+
expectedCount: 0,
354
+
expectedSecrets: map[string]string{},
355
+
expectError: false,
356
+
},
357
+
{
358
+
name: "get unlocked secrets from empty database",
359
+
setupSecrets: []UnlockedSecret{},
360
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
361
+
expectedCount: 0,
362
+
expectedSecrets: map[string]string{},
363
+
expectError: false,
364
+
},
365
+
}
366
+
367
+
for _, tt := range tests {
368
+
t.Run(tt.name, func(t *testing.T) {
369
+
manager := createInMemoryDB(t)
370
+
defer manager.db.Close()
371
+
372
+
// Setup secrets
373
+
for _, secret := range tt.setupSecrets {
374
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
375
+
t.Fatalf("Failed to setup secret: %v", err)
376
+
}
377
+
}
378
+
379
+
// Test getting unlocked secrets
380
+
unlockedSecrets, err := manager.GetSecretsUnlocked(context.Background(), tt.queryRepo)
381
+
if tt.expectError && err == nil {
382
+
t.Error("Expected error but got none")
383
+
return
384
+
}
385
+
if !tt.expectError && err != nil {
386
+
t.Fatalf("Unexpected error: %v", err)
387
+
}
388
+
389
+
if len(unlockedSecrets) != tt.expectedCount {
390
+
t.Errorf("Expected %d secrets, got %d", tt.expectedCount, len(unlockedSecrets))
391
+
}
392
+
393
+
// Verify keys, values, and metadata
394
+
for _, us := range unlockedSecrets {
395
+
expectedValue, exists := tt.expectedSecrets[us.Key]
396
+
if !exists {
397
+
t.Errorf("Unexpected key: %s", us.Key)
398
+
continue
399
+
}
400
+
if us.Value != expectedValue {
401
+
t.Errorf("Expected value %s for key %s, got %s", expectedValue, us.Key, us.Value)
402
+
}
403
+
if us.Repo != tt.queryRepo {
404
+
t.Errorf("Expected repo %s, got %s", tt.queryRepo, us.Repo)
405
+
}
406
+
if us.CreatedBy == "" {
407
+
t.Error("Expected CreatedBy to be present")
408
+
}
409
+
if us.CreatedAt.IsZero() {
410
+
t.Error("Expected CreatedAt to be set")
411
+
}
412
+
}
413
+
})
414
+
}
415
+
}
416
+
417
+
// Test that demonstrates interface usage with table-driven tests
418
+
func TestManagerInterface_Usage(t *testing.T) {
419
+
tests := []struct {
420
+
name string
421
+
operations []func(Manager) error
422
+
expectError bool
423
+
}{
424
+
{
425
+
name: "successful workflow",
426
+
operations: []func(Manager) error{
427
+
func(m Manager) error {
428
+
secret := createTestSecret("interface.test/repo", "test_key", "test_value", "did:plc:user")
429
+
return m.AddSecret(context.Background(), secret)
430
+
},
431
+
func(m Manager) error {
432
+
_, err := m.GetSecretsLocked(context.Background(), DidSlashRepo("interface.test/repo"))
433
+
return err
434
+
},
435
+
func(m Manager) error {
436
+
_, err := m.GetSecretsUnlocked(context.Background(), DidSlashRepo("interface.test/repo"))
437
+
return err
438
+
},
439
+
func(m Manager) error {
440
+
secret := Secret[any]{
441
+
Key: "test_key",
442
+
Repo: DidSlashRepo("interface.test/repo"),
443
+
}
444
+
return m.RemoveSecret(context.Background(), secret)
445
+
},
446
+
},
447
+
expectError: false,
448
+
},
449
+
{
450
+
name: "error on duplicate key",
451
+
operations: []func(Manager) error{
452
+
func(m Manager) error {
453
+
secret := createTestSecret("interface.test/repo", "dup_key", "value1", "did:plc:user")
454
+
return m.AddSecret(context.Background(), secret)
455
+
},
456
+
func(m Manager) error {
457
+
secret := createTestSecret("interface.test/repo", "dup_key", "value2", "did:plc:user")
458
+
return m.AddSecret(context.Background(), secret) // Should return ErrKeyAlreadyPresent
459
+
},
460
+
},
461
+
expectError: true,
462
+
},
463
+
}
464
+
465
+
for _, tt := range tests {
466
+
t.Run(tt.name, func(t *testing.T) {
467
+
var manager Manager = createInMemoryDB(t)
468
+
defer func() {
469
+
if sqliteManager, ok := manager.(*SqliteManager); ok {
470
+
sqliteManager.db.Close()
471
+
}
472
+
}()
473
+
474
+
var finalErr error
475
+
for i, operation := range tt.operations {
476
+
if err := operation(manager); err != nil {
477
+
finalErr = err
478
+
t.Logf("Operation %d returned error: %v", i, err)
479
+
}
480
+
}
481
+
482
+
if tt.expectError && finalErr == nil {
483
+
t.Error("Expected error but got none")
484
+
}
485
+
if !tt.expectError && finalErr != nil {
486
+
t.Errorf("Unexpected error: %v", finalErr)
487
+
}
488
+
})
489
+
}
490
+
}
491
+
492
+
// Integration test with table-driven scenarios
493
+
func TestSqliteManager_Integration(t *testing.T) {
494
+
tests := []struct {
495
+
name string
496
+
scenario func(*testing.T, *SqliteManager)
497
+
}{
498
+
{
499
+
name: "multi-repo secret management",
500
+
scenario: func(t *testing.T, manager *SqliteManager) {
501
+
repo1 := DidSlashRepo("example1.com/repo")
502
+
repo2 := DidSlashRepo("example2.com/repo")
503
+
504
+
secrets := []UnlockedSecret{
505
+
createTestSecret(string(repo1), "db_password", "super_secret_123", "did:plc:admin"),
506
+
createTestSecret(string(repo1), "api_key", "api_key_456", "did:plc:user1"),
507
+
createTestSecret(string(repo2), "token", "bearer_token_789", "did:plc:user2"),
508
+
}
509
+
510
+
// Add all secrets
511
+
for _, secret := range secrets {
512
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
513
+
t.Fatalf("Failed to add secret %s: %v", secret.Key, err)
514
+
}
515
+
}
516
+
517
+
// Verify counts
518
+
locked1, _ := manager.GetSecretsLocked(context.Background(), repo1)
519
+
locked2, _ := manager.GetSecretsLocked(context.Background(), repo2)
520
+
521
+
if len(locked1) != 2 {
522
+
t.Errorf("Expected 2 secrets for repo1, got %d", len(locked1))
523
+
}
524
+
if len(locked2) != 1 {
525
+
t.Errorf("Expected 1 secret for repo2, got %d", len(locked2))
526
+
}
527
+
528
+
// Remove and verify
529
+
secretToRemove := Secret[any]{Key: "db_password", Repo: repo1}
530
+
if err := manager.RemoveSecret(context.Background(), secretToRemove); err != nil {
531
+
t.Fatalf("Failed to remove secret: %v", err)
532
+
}
533
+
534
+
locked1After, _ := manager.GetSecretsLocked(context.Background(), repo1)
535
+
if len(locked1After) != 1 {
536
+
t.Errorf("Expected 1 secret for repo1 after removal, got %d", len(locked1After))
537
+
}
538
+
if locked1After[0].Key != "api_key" {
539
+
t.Errorf("Expected remaining secret to be 'api_key', got %s", locked1After[0].Key)
540
+
}
541
+
},
542
+
},
543
+
{
544
+
name: "empty database operations",
545
+
scenario: func(t *testing.T, manager *SqliteManager) {
546
+
repo := DidSlashRepo("empty.test/repo")
547
+
548
+
// Operations on empty database should not error
549
+
locked, err := manager.GetSecretsLocked(context.Background(), repo)
550
+
if err != nil {
551
+
t.Errorf("GetSecretsLocked on empty DB failed: %v", err)
552
+
}
553
+
if len(locked) != 0 {
554
+
t.Errorf("Expected 0 secrets, got %d", len(locked))
555
+
}
556
+
557
+
unlocked, err := manager.GetSecretsUnlocked(context.Background(), repo)
558
+
if err != nil {
559
+
t.Errorf("GetSecretsUnlocked on empty DB failed: %v", err)
560
+
}
561
+
if len(unlocked) != 0 {
562
+
t.Errorf("Expected 0 secrets, got %d", len(unlocked))
563
+
}
564
+
565
+
// Remove from empty should return ErrKeyNotFound
566
+
nonExistent := Secret[any]{Key: "none", Repo: repo}
567
+
err = manager.RemoveSecret(context.Background(), nonExistent)
568
+
if err != ErrKeyNotFound {
569
+
t.Errorf("Expected ErrKeyNotFound, got %v", err)
570
+
}
571
+
},
572
+
},
573
+
}
574
+
575
+
for _, tt := range tests {
576
+
t.Run(tt.name, func(t *testing.T) {
577
+
manager := createInMemoryDB(t)
578
+
defer manager.db.Close()
579
+
tt.scenario(t, manager)
580
+
})
581
+
}
582
+
}
583
+
584
+
func TestSqliteManager_StopperInterface(t *testing.T) {
585
+
manager := &SqliteManager{}
586
+
587
+
// Verify that SqliteManager does NOT implement the Stopper interface
588
+
_, ok := interface{}(manager).(Stopper)
589
+
assert.False(t, ok, "SqliteManager should NOT implement Stopper interface")
590
+
}
+81
-42
spindle/server.go
+81
-42
spindle/server.go
···
2
3
import (
4
"context"
5
"encoding/json"
6
"fmt"
7
"log/slog"
···
11
"tangled.sh/tangled.sh/core/api/tangled"
12
"tangled.sh/tangled.sh/core/eventconsumer"
13
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
14
"tangled.sh/tangled.sh/core/jetstream"
15
"tangled.sh/tangled.sh/core/log"
16
"tangled.sh/tangled.sh/core/notifier"
···
20
"tangled.sh/tangled.sh/core/spindle/engine"
21
"tangled.sh/tangled.sh/core/spindle/models"
22
"tangled.sh/tangled.sh/core/spindle/queue"
23
)
24
25
const (
26
rbacDomain = "thisserver"
27
)
28
29
type Spindle struct {
30
-
jc *jetstream.JetstreamClient
31
-
db *db.DB
32
-
e *rbac.Enforcer
33
-
l *slog.Logger
34
-
n *notifier.Notifier
35
-
eng *engine.Engine
36
-
jq *queue.Queue
37
-
cfg *config.Config
38
-
ks *eventconsumer.Consumer
39
}
40
41
func Run(ctx context.Context) error {
···
59
60
n := notifier.New()
61
62
-
eng, err := engine.New(ctx, cfg, d, &n)
63
if err != nil {
64
return err
65
}
···
69
collections := []string{
70
tangled.SpindleMemberNSID,
71
tangled.RepoNSID,
72
}
73
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
74
if err != nil {
···
76
}
77
jc.AddDid(cfg.Server.Owner)
78
79
spindle := Spindle{
80
-
jc: jc,
81
-
e: e,
82
-
db: d,
83
-
l: logger,
84
-
n: &n,
85
-
eng: eng,
86
-
jq: jq,
87
-
cfg: cfg,
88
}
89
90
err = e.AddSpindle(rbacDomain)
···
100
// starts a job queue runner in the background
101
jq.Start()
102
defer jq.Stop()
103
104
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
105
if err != nil {
···
144
mux := chi.NewRouter()
145
146
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
147
-
w.Write([]byte(
148
-
` ****
149
-
*** ***
150
-
*** ** ****** **
151
-
** * *****
152
-
* ** **
153
-
* * * ***************
154
-
** ** *# **
155
-
* ** ** *** **
156
-
* * ** ** * ******
157
-
* ** ** * ** * *
158
-
** ** *** ** ** *
159
-
** ** * ** * *
160
-
** **** ** * *
161
-
** *** ** ** **
162
-
*** ** *****
163
-
********************
164
-
**
165
-
*
166
-
#**************
167
-
**
168
-
********
169
-
170
-
This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle`))
171
})
172
mux.HandleFunc("/events", s.Events)
173
mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) {
174
w.Write([]byte(s.cfg.Server.Owner))
175
})
176
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
177
return mux
178
}
179
180
func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
···
2
3
import (
4
"context"
5
+
_ "embed"
6
"encoding/json"
7
"fmt"
8
"log/slog"
···
12
"tangled.sh/tangled.sh/core/api/tangled"
13
"tangled.sh/tangled.sh/core/eventconsumer"
14
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
15
+
"tangled.sh/tangled.sh/core/idresolver"
16
"tangled.sh/tangled.sh/core/jetstream"
17
"tangled.sh/tangled.sh/core/log"
18
"tangled.sh/tangled.sh/core/notifier"
···
22
"tangled.sh/tangled.sh/core/spindle/engine"
23
"tangled.sh/tangled.sh/core/spindle/models"
24
"tangled.sh/tangled.sh/core/spindle/queue"
25
+
"tangled.sh/tangled.sh/core/spindle/secrets"
26
+
"tangled.sh/tangled.sh/core/spindle/xrpc"
27
)
28
29
+
//go:embed motd
30
+
var motd []byte
31
+
32
const (
33
rbacDomain = "thisserver"
34
)
35
36
type Spindle struct {
37
+
jc *jetstream.JetstreamClient
38
+
db *db.DB
39
+
e *rbac.Enforcer
40
+
l *slog.Logger
41
+
n *notifier.Notifier
42
+
eng *engine.Engine
43
+
jq *queue.Queue
44
+
cfg *config.Config
45
+
ks *eventconsumer.Consumer
46
+
res *idresolver.Resolver
47
+
vault secrets.Manager
48
}
49
50
func Run(ctx context.Context) error {
···
68
69
n := notifier.New()
70
71
+
var vault secrets.Manager
72
+
switch cfg.Server.Secrets.Provider {
73
+
case "openbao":
74
+
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
75
+
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
76
+
}
77
+
vault, err = secrets.NewOpenBaoManager(
78
+
cfg.Server.Secrets.OpenBao.ProxyAddr,
79
+
logger,
80
+
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
81
+
)
82
+
if err != nil {
83
+
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
84
+
}
85
+
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
86
+
case "sqlite", "":
87
+
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
88
+
if err != nil {
89
+
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
90
+
}
91
+
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
92
+
default:
93
+
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
94
+
}
95
+
96
+
eng, err := engine.New(ctx, cfg, d, &n, vault)
97
if err != nil {
98
return err
99
}
···
103
collections := []string{
104
tangled.SpindleMemberNSID,
105
tangled.RepoNSID,
106
+
tangled.RepoCollaboratorNSID,
107
}
108
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
109
if err != nil {
···
111
}
112
jc.AddDid(cfg.Server.Owner)
113
114
+
resolver := idresolver.DefaultResolver()
115
+
116
spindle := Spindle{
117
+
jc: jc,
118
+
e: e,
119
+
db: d,
120
+
l: logger,
121
+
n: &n,
122
+
eng: eng,
123
+
jq: jq,
124
+
cfg: cfg,
125
+
res: resolver,
126
+
vault: vault,
127
}
128
129
err = e.AddSpindle(rbacDomain)
···
139
// starts a job queue runner in the background
140
jq.Start()
141
defer jq.Stop()
142
+
143
+
// Stop vault token renewal if it implements Stopper
144
+
if stopper, ok := vault.(secrets.Stopper); ok {
145
+
defer stopper.Stop()
146
+
}
147
148
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
149
if err != nil {
···
188
mux := chi.NewRouter()
189
190
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
191
+
w.Write(motd)
192
})
193
mux.HandleFunc("/events", s.Events)
194
mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) {
195
w.Write([]byte(s.cfg.Server.Owner))
196
})
197
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
198
+
199
+
mux.Mount("/xrpc", s.XrpcRouter())
200
return mux
201
+
}
202
+
203
+
func (s *Spindle) XrpcRouter() http.Handler {
204
+
logger := s.l.With("route", "xrpc")
205
+
206
+
x := xrpc.Xrpc{
207
+
Logger: logger,
208
+
Db: s.db,
209
+
Enforcer: s.e,
210
+
Engine: s.eng,
211
+
Config: s.cfg,
212
+
Resolver: s.res,
213
+
Vault: s.vault,
214
+
}
215
+
216
+
return x.Router()
217
}
218
219
func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
+91
spindle/xrpc/add_secret.go
+91
spindle/xrpc/add_secret.go
···
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/api/atproto"
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"github.com/bluesky-social/indigo/xrpc"
12
+
securejoin "github.com/cyphar/filepath-securejoin"
13
+
"tangled.sh/tangled.sh/core/api/tangled"
14
+
"tangled.sh/tangled.sh/core/rbac"
15
+
"tangled.sh/tangled.sh/core/spindle/secrets"
16
+
)
17
+
18
+
func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
19
+
l := x.Logger
20
+
fail := func(e XrpcError) {
21
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
22
+
writeError(w, e, http.StatusBadRequest)
23
+
}
24
+
25
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
26
+
if !ok {
27
+
fail(MissingActorDidError)
28
+
return
29
+
}
30
+
31
+
var data tangled.RepoAddSecret_Input
32
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
33
+
fail(GenericError(err))
34
+
return
35
+
}
36
+
37
+
if err := secrets.ValidateKey(data.Key); err != nil {
38
+
fail(GenericError(err))
39
+
return
40
+
}
41
+
42
+
// unfortunately we have to resolve repo-at here
43
+
repoAt, err := syntax.ParseATURI(data.Repo)
44
+
if err != nil {
45
+
fail(InvalidRepoError(data.Repo))
46
+
return
47
+
}
48
+
49
+
// resolve this aturi to extract the repo record
50
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
51
+
if err != nil || ident.Handle.IsInvalidHandle() {
52
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
53
+
return
54
+
}
55
+
56
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
57
+
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
58
+
if err != nil {
59
+
fail(GenericError(err))
60
+
return
61
+
}
62
+
63
+
repo := resp.Value.Val.(*tangled.Repo)
64
+
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
65
+
if err != nil {
66
+
fail(GenericError(err))
67
+
return
68
+
}
69
+
70
+
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
71
+
l.Error("insufficent permissions", "did", actorDid.String())
72
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
73
+
return
74
+
}
75
+
76
+
secret := secrets.UnlockedSecret{
77
+
Repo: secrets.DidSlashRepo(didPath),
78
+
Key: data.Key,
79
+
Value: data.Value,
80
+
CreatedAt: time.Now(),
81
+
CreatedBy: actorDid,
82
+
}
83
+
err = x.Vault.AddSecret(r.Context(), secret)
84
+
if err != nil {
85
+
l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err)
86
+
writeError(w, GenericError(err), http.StatusInternalServerError)
87
+
return
88
+
}
89
+
90
+
w.WriteHeader(http.StatusOK)
91
+
}
+91
spindle/xrpc/list_secrets.go
+91
spindle/xrpc/list_secrets.go
···
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/api/atproto"
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"github.com/bluesky-social/indigo/xrpc"
12
+
securejoin "github.com/cyphar/filepath-securejoin"
13
+
"tangled.sh/tangled.sh/core/api/tangled"
14
+
"tangled.sh/tangled.sh/core/rbac"
15
+
"tangled.sh/tangled.sh/core/spindle/secrets"
16
+
)
17
+
18
+
func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
19
+
l := x.Logger
20
+
fail := func(e XrpcError) {
21
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
22
+
writeError(w, e, http.StatusBadRequest)
23
+
}
24
+
25
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
26
+
if !ok {
27
+
fail(MissingActorDidError)
28
+
return
29
+
}
30
+
31
+
repoParam := r.URL.Query().Get("repo")
32
+
if repoParam == "" {
33
+
fail(GenericError(fmt.Errorf("empty params")))
34
+
return
35
+
}
36
+
37
+
// unfortunately we have to resolve repo-at here
38
+
repoAt, err := syntax.ParseATURI(repoParam)
39
+
if err != nil {
40
+
fail(InvalidRepoError(repoParam))
41
+
return
42
+
}
43
+
44
+
// resolve this aturi to extract the repo record
45
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
46
+
if err != nil || ident.Handle.IsInvalidHandle() {
47
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
48
+
return
49
+
}
50
+
51
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
52
+
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
53
+
if err != nil {
54
+
fail(GenericError(err))
55
+
return
56
+
}
57
+
58
+
repo := resp.Value.Val.(*tangled.Repo)
59
+
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
60
+
if err != nil {
61
+
fail(GenericError(err))
62
+
return
63
+
}
64
+
65
+
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
66
+
l.Error("insufficent permissions", "did", actorDid.String())
67
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
68
+
return
69
+
}
70
+
71
+
ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath))
72
+
if err != nil {
73
+
l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err)
74
+
writeError(w, GenericError(err), http.StatusInternalServerError)
75
+
return
76
+
}
77
+
78
+
var out tangled.RepoListSecrets_Output
79
+
for _, l := range ls {
80
+
out.Secrets = append(out.Secrets, &tangled.RepoListSecrets_Secret{
81
+
Repo: repoAt.String(),
82
+
Key: l.Key,
83
+
CreatedAt: l.CreatedAt.Format(time.RFC3339),
84
+
CreatedBy: l.CreatedBy.String(),
85
+
})
86
+
}
87
+
88
+
w.Header().Set("Content-Type", "application/json")
89
+
w.WriteHeader(http.StatusOK)
90
+
json.NewEncoder(w).Encode(out)
91
+
}
+82
spindle/xrpc/remove_secret.go
+82
spindle/xrpc/remove_secret.go
···
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
8
+
"github.com/bluesky-social/indigo/api/atproto"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"github.com/bluesky-social/indigo/xrpc"
11
+
securejoin "github.com/cyphar/filepath-securejoin"
12
+
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.sh/tangled.sh/core/rbac"
14
+
"tangled.sh/tangled.sh/core/spindle/secrets"
15
+
)
16
+
17
+
func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
18
+
l := x.Logger
19
+
fail := func(e XrpcError) {
20
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
21
+
writeError(w, e, http.StatusBadRequest)
22
+
}
23
+
24
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
25
+
if !ok {
26
+
fail(MissingActorDidError)
27
+
return
28
+
}
29
+
30
+
var data tangled.RepoRemoveSecret_Input
31
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
32
+
fail(GenericError(err))
33
+
return
34
+
}
35
+
36
+
// unfortunately we have to resolve repo-at here
37
+
repoAt, err := syntax.ParseATURI(data.Repo)
38
+
if err != nil {
39
+
fail(InvalidRepoError(data.Repo))
40
+
return
41
+
}
42
+
43
+
// resolve this aturi to extract the repo record
44
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
45
+
if err != nil || ident.Handle.IsInvalidHandle() {
46
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
47
+
return
48
+
}
49
+
50
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
51
+
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
52
+
if err != nil {
53
+
fail(GenericError(err))
54
+
return
55
+
}
56
+
57
+
repo := resp.Value.Val.(*tangled.Repo)
58
+
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
59
+
if err != nil {
60
+
fail(GenericError(err))
61
+
return
62
+
}
63
+
64
+
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
65
+
l.Error("insufficent permissions", "did", actorDid.String())
66
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
67
+
return
68
+
}
69
+
70
+
secret := secrets.Secret[any]{
71
+
Repo: secrets.DidSlashRepo(didPath),
72
+
Key: data.Key,
73
+
}
74
+
err = x.Vault.RemoveSecret(r.Context(), secret)
75
+
if err != nil {
76
+
l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err)
77
+
writeError(w, GenericError(err), http.StatusInternalServerError)
78
+
return
79
+
}
80
+
81
+
w.WriteHeader(http.StatusOK)
82
+
}
+147
spindle/xrpc/xrpc.go
+147
spindle/xrpc/xrpc.go
···
···
1
+
package xrpc
2
+
3
+
import (
4
+
"context"
5
+
_ "embed"
6
+
"encoding/json"
7
+
"fmt"
8
+
"log/slog"
9
+
"net/http"
10
+
"strings"
11
+
12
+
"github.com/bluesky-social/indigo/atproto/auth"
13
+
"github.com/go-chi/chi/v5"
14
+
15
+
"tangled.sh/tangled.sh/core/api/tangled"
16
+
"tangled.sh/tangled.sh/core/idresolver"
17
+
"tangled.sh/tangled.sh/core/rbac"
18
+
"tangled.sh/tangled.sh/core/spindle/config"
19
+
"tangled.sh/tangled.sh/core/spindle/db"
20
+
"tangled.sh/tangled.sh/core/spindle/engine"
21
+
"tangled.sh/tangled.sh/core/spindle/secrets"
22
+
)
23
+
24
+
const ActorDid string = "ActorDid"
25
+
26
+
type Xrpc struct {
27
+
Logger *slog.Logger
28
+
Db *db.DB
29
+
Enforcer *rbac.Enforcer
30
+
Engine *engine.Engine
31
+
Config *config.Config
32
+
Resolver *idresolver.Resolver
33
+
Vault secrets.Manager
34
+
}
35
+
36
+
func (x *Xrpc) Router() http.Handler {
37
+
r := chi.NewRouter()
38
+
39
+
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
40
+
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
41
+
r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
42
+
43
+
return r
44
+
}
45
+
46
+
func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler {
47
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48
+
l := x.Logger.With("url", r.URL)
49
+
50
+
token := r.Header.Get("Authorization")
51
+
token = strings.TrimPrefix(token, "Bearer ")
52
+
53
+
s := auth.ServiceAuthValidator{
54
+
Audience: x.Config.Server.Did().String(),
55
+
Dir: x.Resolver.Directory(),
56
+
}
57
+
58
+
did, err := s.Validate(r.Context(), token, nil)
59
+
if err != nil {
60
+
l.Error("signature verification failed", "err", err)
61
+
writeError(w, AuthError(err), http.StatusForbidden)
62
+
return
63
+
}
64
+
65
+
r = r.WithContext(
66
+
context.WithValue(r.Context(), ActorDid, did),
67
+
)
68
+
69
+
next.ServeHTTP(w, r)
70
+
})
71
+
}
72
+
73
+
type XrpcError struct {
74
+
Tag string `json:"error"`
75
+
Message string `json:"message"`
76
+
}
77
+
78
+
func NewXrpcError(opts ...ErrOpt) XrpcError {
79
+
x := XrpcError{}
80
+
for _, o := range opts {
81
+
o(&x)
82
+
}
83
+
84
+
return x
85
+
}
86
+
87
+
type ErrOpt = func(xerr *XrpcError)
88
+
89
+
func WithTag(tag string) ErrOpt {
90
+
return func(xerr *XrpcError) {
91
+
xerr.Tag = tag
92
+
}
93
+
}
94
+
95
+
func WithMessage[S ~string](s S) ErrOpt {
96
+
return func(xerr *XrpcError) {
97
+
xerr.Message = string(s)
98
+
}
99
+
}
100
+
101
+
func WithError(e error) ErrOpt {
102
+
return func(xerr *XrpcError) {
103
+
xerr.Message = e.Error()
104
+
}
105
+
}
106
+
107
+
var MissingActorDidError = NewXrpcError(
108
+
WithTag("MissingActorDid"),
109
+
WithMessage("actor DID not supplied"),
110
+
)
111
+
112
+
var AuthError = func(err error) XrpcError {
113
+
return NewXrpcError(
114
+
WithTag("Auth"),
115
+
WithError(fmt.Errorf("signature verification failed: %w", err)),
116
+
)
117
+
}
118
+
119
+
var InvalidRepoError = func(r string) XrpcError {
120
+
return NewXrpcError(
121
+
WithTag("InvalidRepo"),
122
+
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
123
+
)
124
+
}
125
+
126
+
func GenericError(err error) XrpcError {
127
+
return NewXrpcError(
128
+
WithTag("Generic"),
129
+
WithError(err),
130
+
)
131
+
}
132
+
133
+
var AccessControlError = func(d string) XrpcError {
134
+
return NewXrpcError(
135
+
WithTag("AccessControl"),
136
+
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
137
+
)
138
+
}
139
+
140
+
// this is slightly different from http_util::write_error to follow the spec:
141
+
//
142
+
// the json object returned must include an "error" and a "message"
143
+
func writeError(w http.ResponseWriter, e XrpcError, status int) {
144
+
w.Header().Set("Content-Type", "application/json")
145
+
w.WriteHeader(status)
146
+
json.NewEncoder(w).Encode(e)
147
+
}