+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
5854
5855
5855
return nil
5856
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
+
}
5857
6055
func (t *RepoIssue) MarshalCBOR(w io.Writer) error {
5858
6056
if t == nil {
5859
6057
_, err := w.Write(cbg.CborNull)
···
8225
8423
8226
8424
return nil
8227
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
10
)
11
11
12
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"`
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"`
18
19
}
19
20
20
21
type OAuthConfig struct {
···
59
60
DB int `env:"DB, default=0"`
60
61
}
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
+
62
73
func (cfg RedisConfig) ToURL() string {
63
74
u := &url.URL{
64
75
Scheme: "redis",
···
84
95
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
85
96
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
86
97
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
98
+
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
99
+
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
87
100
}
88
101
89
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
355
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
356
356
357
357
-- constraints
358
-
foreign key (did, instance) references spindles(owner, instance) on delete cascade,
359
358
unique (did, instance, subject)
360
359
);
361
360
···
435
434
bytes integer not null check (bytes >= 0),
436
435
437
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)
438
459
);
439
460
440
461
create table if not exists migrations (
···
579
600
return nil
580
601
})
581
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
+
582
658
return &DB{db}, nil
583
659
}
584
660
···
654
730
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
655
731
if kind == reflect.Slice || kind == reflect.Array {
656
732
if rv.Len() == 0 {
657
-
panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key))
733
+
// always false
734
+
return "1 = 0"
658
735
}
659
736
660
737
placeholders := make([]string, rv.Len())
···
673
750
kind := rv.Kind()
674
751
if kind == reflect.Slice || kind == reflect.Array {
675
752
if rv.Len() == 0 {
676
-
panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key))
753
+
return nil
677
754
}
678
755
679
756
out := make([]any, rv.Len())
+16
-2
appview/db/email.go
+16
-2
appview/db/email.go
···
103
103
query := `
104
104
select email, did
105
105
from emails
106
-
where
107
-
verified = ?
106
+
where
107
+
verified = ?
108
108
and email in (` + strings.Join(placeholders, ",") + `)
109
109
`
110
110
···
153
153
`
154
154
var count int
155
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)
156
170
if err != nil {
157
171
return false, err
158
172
}
+2
-2
appview/db/follow.go
+2
-2
appview/db/follow.go
···
12
12
Rkey string
13
13
}
14
14
15
-
func AddFollow(e Execer, userDid, subjectDid, rkey string) error {
15
+
func AddFollow(e Execer, follow *Follow) error {
16
16
query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)`
17
-
_, err := e.Exec(query, userDid, subjectDid, rkey)
17
+
_, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey)
18
18
return err
19
19
}
20
20
+17
-12
appview/db/issues.go
+17
-12
appview/db/issues.go
···
9
9
)
10
10
11
11
type Issue struct {
12
+
ID int64
12
13
RepoAt syntax.ATURI
13
14
OwnerDid string
14
15
IssueId int
···
65
66
66
67
issue.IssueId = nextId
67
68
68
-
_, err = tx.Exec(`
69
+
res, err := tx.Exec(`
69
70
insert into issues (repo_at, owner_did, issue_id, title, body)
70
71
values (?, ?, ?, ?, ?)
71
72
`, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body)
72
73
if err != nil {
73
74
return err
74
75
}
76
+
77
+
lastID, err := res.LastInsertId()
78
+
if err != nil {
79
+
return err
80
+
}
81
+
issue.ID = lastID
75
82
76
83
if err := tx.Commit(); err != nil {
77
84
return err
···
89
96
var issueAt string
90
97
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
91
98
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
}
99
100
100
101
func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
···
114
115
`
115
116
with numbered_issue as (
116
117
select
118
+
i.id,
117
119
i.owner_did,
118
120
i.issue_id,
119
121
i.created,
···
132
134
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
133
135
)
134
136
select
137
+
id,
135
138
owner_did,
136
139
issue_id,
137
140
created,
···
153
156
var issue Issue
154
157
var createdAt string
155
158
var metadata IssueMetadata
156
-
err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
159
+
err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
157
160
if err != nil {
158
161
return nil, err
159
162
}
···
182
185
183
186
rows, err := e.Query(
184
187
`select
188
+
i.id,
185
189
i.owner_did,
186
190
i.repo_at,
187
191
i.issue_id,
···
213
217
var issueCreatedAt, repoCreatedAt string
214
218
var repo Repo
215
219
err := rows.Scan(
220
+
&issue.ID,
216
221
&issue.OwnerDid,
217
222
&issue.RepoAt,
218
223
&issue.IssueId,
···
257
262
}
258
263
259
264
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 = ?`
265
+
query := `select id, owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?`
261
266
row := e.QueryRow(query, repoAt, issueId)
262
267
263
268
var issue Issue
264
269
var createdAt string
265
-
err := row.Scan(&issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open)
270
+
err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open)
266
271
if err != nil {
267
272
return nil, err
268
273
}
···
277
282
}
278
283
279
284
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 = ?`
285
+
query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?`
281
286
row := e.QueryRow(query, repoAt, issueId)
282
287
283
288
var issue Issue
284
289
var createdAt string
285
-
err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt)
290
+
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt)
286
291
if err != nil {
287
292
return nil, nil, err
288
293
}
+108
appview/db/profile.go
+108
appview/db/profile.go
···
348
348
return tx.Commit()
349
349
}
350
350
351
+
func GetProfiles(e Execer, filters ...filter) ([]Profile, error) {
352
+
var conditions []string
353
+
var args []any
354
+
for _, filter := range filters {
355
+
conditions = append(conditions, filter.Condition())
356
+
args = append(args, filter.Arg()...)
357
+
}
358
+
359
+
whereClause := ""
360
+
if conditions != nil {
361
+
whereClause = " where " + strings.Join(conditions, " and ")
362
+
}
363
+
364
+
profilesQuery := fmt.Sprintf(
365
+
`select
366
+
id,
367
+
did,
368
+
description,
369
+
include_bluesky,
370
+
location
371
+
from
372
+
profile
373
+
%s`,
374
+
whereClause,
375
+
)
376
+
rows, err := e.Query(profilesQuery, args...)
377
+
if err != nil {
378
+
return nil, err
379
+
}
380
+
381
+
profileMap := make(map[string]*Profile)
382
+
for rows.Next() {
383
+
var profile Profile
384
+
var includeBluesky int
385
+
386
+
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
387
+
if err != nil {
388
+
return nil, err
389
+
}
390
+
391
+
if includeBluesky != 0 {
392
+
profile.IncludeBluesky = true
393
+
}
394
+
395
+
profileMap[profile.Did] = &profile
396
+
}
397
+
if err = rows.Err(); err != nil {
398
+
return nil, err
399
+
}
400
+
401
+
// populate profile links
402
+
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(profileMap)), ", ")
403
+
args = make([]any, len(profileMap))
404
+
i := 0
405
+
for did := range profileMap {
406
+
args[i] = did
407
+
i++
408
+
}
409
+
410
+
linksQuery := fmt.Sprintf("select link, did from profile_links where did in (%s)", inClause)
411
+
rows, err = e.Query(linksQuery, args...)
412
+
if err != nil {
413
+
return nil, err
414
+
}
415
+
idxs := make(map[string]int)
416
+
for did := range profileMap {
417
+
idxs[did] = 0
418
+
}
419
+
for rows.Next() {
420
+
var link, did string
421
+
if err = rows.Scan(&link, &did); err != nil {
422
+
return nil, err
423
+
}
424
+
425
+
idx := idxs[did]
426
+
profileMap[did].Links[idx] = link
427
+
idxs[did] = idx + 1
428
+
}
429
+
430
+
pinsQuery := fmt.Sprintf("select at_uri, did from profile_pinned_repositories where did in (%s)", inClause)
431
+
rows, err = e.Query(pinsQuery, args...)
432
+
if err != nil {
433
+
return nil, err
434
+
}
435
+
idxs = make(map[string]int)
436
+
for did := range profileMap {
437
+
idxs[did] = 0
438
+
}
439
+
for rows.Next() {
440
+
var link syntax.ATURI
441
+
var did string
442
+
if err = rows.Scan(&link, &did); err != nil {
443
+
return nil, err
444
+
}
445
+
446
+
idx := idxs[did]
447
+
profileMap[did].PinnedRepos[idx] = link
448
+
idxs[did] = idx + 1
449
+
}
450
+
451
+
var profiles []Profile
452
+
for _, p := range profileMap {
453
+
profiles = append(profiles, *p)
454
+
}
455
+
456
+
return profiles, nil
457
+
}
458
+
351
459
func GetProfile(e Execer, did string) (*Profile, error) {
352
460
var profile Profile
353
461
profile.Did = did
+1
-34
appview/db/repos.go
+1
-34
appview/db/repos.go
···
106
106
from
107
107
repos r
108
108
%s
109
+
order by created desc
109
110
%s`,
110
111
whereClause,
111
112
limitClause,
···
549
550
return &repo, nil
550
551
}
551
552
552
-
func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error {
553
-
_, err := e.Exec(
554
-
`insert into collaborators (did, repo)
555
-
values (?, (select id from repos where did = ? and name = ? and knot = ?));`,
556
-
collaborator, repoOwnerDid, repoName, repoKnot)
557
-
return err
558
-
}
559
-
560
553
func UpdateDescription(e Execer, repoAt, newDescription string) error {
561
554
_, err := e.Exec(
562
555
`update repos set description = ? where at_uri = ?`, newDescription, repoAt)
···
567
560
_, err := e.Exec(
568
561
`update repos set spindle = ? where at_uri = ?`, spindle, repoAt)
569
562
return err
570
-
}
571
-
572
-
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
573
-
rows, err := e.Query(`select repo from collaborators where did = ?`, collaborator)
574
-
if err != nil {
575
-
return nil, err
576
-
}
577
-
defer rows.Close()
578
-
579
-
var repoIds []int
580
-
for rows.Next() {
581
-
var id int
582
-
err := rows.Scan(&id)
583
-
if err != nil {
584
-
return nil, err
585
-
}
586
-
repoIds = append(repoIds, id)
587
-
}
588
-
if err := rows.Err(); err != nil {
589
-
return nil, err
590
-
}
591
-
if repoIds == nil {
592
-
return nil, nil
593
-
}
594
-
595
-
return GetRepos(e, 0, FilterIn("id", repoIds))
596
563
}
597
564
598
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
+
}
+92
-2
appview/db/star.go
+92
-2
appview/db/star.go
···
1
1
package db
2
2
3
3
import (
4
+
"fmt"
4
5
"log"
6
+
"strings"
5
7
"time"
6
8
7
9
"github.com/bluesky-social/indigo/atproto/syntax"
···
31
33
return nil
32
34
}
33
35
34
-
func AddStar(e Execer, starredByDid string, repoAt syntax.ATURI, rkey string) error {
36
+
func AddStar(e Execer, star *Star) error {
35
37
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
36
-
_, err := e.Exec(query, starredByDid, repoAt, rkey)
38
+
_, err := e.Exec(
39
+
query,
40
+
star.StarredByDid,
41
+
star.RepoAt.String(),
42
+
star.Rkey,
43
+
)
37
44
return err
38
45
}
39
46
···
91
98
} else {
92
99
return true
93
100
}
101
+
}
102
+
103
+
func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) {
104
+
var conditions []string
105
+
var args []any
106
+
for _, filter := range filters {
107
+
conditions = append(conditions, filter.Condition())
108
+
args = append(args, filter.Arg()...)
109
+
}
110
+
111
+
whereClause := ""
112
+
if conditions != nil {
113
+
whereClause = " where " + strings.Join(conditions, " and ")
114
+
}
115
+
116
+
limitClause := ""
117
+
if limit != 0 {
118
+
limitClause = fmt.Sprintf(" limit %d", limit)
119
+
}
120
+
121
+
repoQuery := fmt.Sprintf(
122
+
`select starred_by_did, repo_at, created, rkey
123
+
from stars
124
+
%s
125
+
order by created desc
126
+
%s`,
127
+
whereClause,
128
+
limitClause,
129
+
)
130
+
rows, err := e.Query(repoQuery, args...)
131
+
if err != nil {
132
+
return nil, err
133
+
}
134
+
135
+
starMap := make(map[string][]Star)
136
+
for rows.Next() {
137
+
var star Star
138
+
var created string
139
+
err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
140
+
if err != nil {
141
+
return nil, err
142
+
}
143
+
144
+
star.Created = time.Now()
145
+
if t, err := time.Parse(time.RFC3339, created); err == nil {
146
+
star.Created = t
147
+
}
148
+
149
+
repoAt := string(star.RepoAt)
150
+
starMap[repoAt] = append(starMap[repoAt], star)
151
+
}
152
+
153
+
// populate *Repo in each star
154
+
args = make([]any, len(starMap))
155
+
i := 0
156
+
for r := range starMap {
157
+
args[i] = r
158
+
i++
159
+
}
160
+
161
+
if len(args) == 0 {
162
+
return nil, nil
163
+
}
164
+
165
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", args))
166
+
if err != nil {
167
+
return nil, err
168
+
}
169
+
170
+
for _, r := range repos {
171
+
if stars, ok := starMap[string(r.RepoAt())]; ok {
172
+
for i := range stars {
173
+
stars[i].Repo = &r
174
+
}
175
+
}
176
+
}
177
+
178
+
var stars []Star
179
+
for _, s := range starMap {
180
+
stars = append(stars, s...)
181
+
}
182
+
183
+
return stars, nil
94
184
}
95
185
96
186
func GetAllStars(e Execer, limit int) ([]Star, error) {
+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
+
}
+136
-27
appview/db/timeline.go
+136
-27
appview/db/timeline.go
···
14
14
15
15
// optional: populate only if Repo is a fork
16
16
Source *Repo
17
+
18
+
// optional: populate only if event is Follow
19
+
*Profile
20
+
*FollowStats
17
21
}
22
+
23
+
type FollowStats struct {
24
+
Followers int
25
+
Following int
26
+
}
27
+
28
+
const Limit = 50
18
29
19
30
// TODO: this gathers heterogenous events from different sources and aggregates
20
31
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
21
32
func MakeTimeline(e Execer) ([]TimelineEvent, error) {
22
33
var events []TimelineEvent
23
-
limit := 50
24
34
25
-
repos, err := GetAllRepos(e, limit)
35
+
repos, err := getTimelineRepos(e)
26
36
if err != nil {
27
37
return nil, err
28
38
}
29
39
30
-
follows, err := GetAllFollows(e, limit)
40
+
stars, err := getTimelineStars(e)
31
41
if err != nil {
32
42
return nil, err
33
43
}
34
44
35
-
stars, err := GetAllStars(e, limit)
45
+
follows, err := getTimelineFollows(e)
36
46
if err != nil {
37
47
return nil, err
38
48
}
39
49
40
-
for _, repo := range repos {
41
-
var sourceRepo *Repo
42
-
if repo.Source != "" {
43
-
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
44
-
if err != nil {
45
-
return nil, err
50
+
events = append(events, repos...)
51
+
events = append(events, stars...)
52
+
events = append(events, follows...)
53
+
54
+
sort.Slice(events, func(i, j int) bool {
55
+
return events[i].EventAt.After(events[j].EventAt)
56
+
})
57
+
58
+
// Limit the slice to 100 events
59
+
if len(events) > Limit {
60
+
events = events[:Limit]
61
+
}
62
+
63
+
return events, nil
64
+
}
65
+
66
+
func getTimelineRepos(e Execer) ([]TimelineEvent, error) {
67
+
repos, err := GetRepos(e, Limit)
68
+
if err != nil {
69
+
return nil, err
70
+
}
71
+
72
+
// fetch all source repos
73
+
var args []string
74
+
for _, r := range repos {
75
+
if r.Source != "" {
76
+
args = append(args, r.Source)
77
+
}
78
+
}
79
+
80
+
var origRepos []Repo
81
+
if args != nil {
82
+
origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args))
83
+
}
84
+
if err != nil {
85
+
return nil, err
86
+
}
87
+
88
+
uriToRepo := make(map[string]Repo)
89
+
for _, r := range origRepos {
90
+
uriToRepo[r.RepoAt().String()] = r
91
+
}
92
+
93
+
var events []TimelineEvent
94
+
for _, r := range repos {
95
+
var source *Repo
96
+
if r.Source != "" {
97
+
if origRepo, ok := uriToRepo[r.Source]; ok {
98
+
source = &origRepo
46
99
}
47
100
}
48
101
49
102
events = append(events, TimelineEvent{
50
-
Repo: &repo,
51
-
EventAt: repo.Created,
52
-
Source: sourceRepo,
103
+
Repo: &r,
104
+
EventAt: r.Created,
105
+
Source: source,
53
106
})
54
107
}
55
108
56
-
for _, follow := range follows {
109
+
return events, nil
110
+
}
111
+
112
+
func getTimelineStars(e Execer) ([]TimelineEvent, error) {
113
+
stars, err := GetStars(e, Limit)
114
+
if err != nil {
115
+
return nil, err
116
+
}
117
+
118
+
// filter star records without a repo
119
+
n := 0
120
+
for _, s := range stars {
121
+
if s.Repo != nil {
122
+
stars[n] = s
123
+
n++
124
+
}
125
+
}
126
+
stars = stars[:n]
127
+
128
+
var events []TimelineEvent
129
+
for _, s := range stars {
57
130
events = append(events, TimelineEvent{
58
-
Follow: &follow,
59
-
EventAt: follow.FollowedAt,
131
+
Star: &s,
132
+
EventAt: s.Created,
60
133
})
61
134
}
62
135
63
-
for _, star := range stars {
64
-
events = append(events, TimelineEvent{
65
-
Star: &star,
66
-
EventAt: star.Created,
67
-
})
136
+
return events, nil
137
+
}
138
+
139
+
func getTimelineFollows(e Execer) ([]TimelineEvent, error) {
140
+
follows, err := GetAllFollows(e, Limit)
141
+
if err != nil {
142
+
return nil, err
143
+
}
144
+
145
+
var subjects []string
146
+
for _, f := range follows {
147
+
subjects = append(subjects, f.SubjectDid)
148
+
}
149
+
150
+
if subjects == nil {
151
+
return nil, nil
152
+
}
153
+
154
+
profileMap := make(map[string]Profile)
155
+
profiles, err := GetProfiles(e, FilterIn("did", subjects))
156
+
if err != nil {
157
+
return nil, err
158
+
}
159
+
for _, p := range profiles {
160
+
profileMap[p.Did] = p
68
161
}
69
162
70
-
sort.Slice(events, func(i, j int) bool {
71
-
return events[i].EventAt.After(events[j].EventAt)
72
-
})
163
+
followStatMap := make(map[string]FollowStats)
164
+
for _, s := range subjects {
165
+
followers, following, err := GetFollowerFollowing(e, s)
166
+
if err != nil {
167
+
return nil, err
168
+
}
169
+
followStatMap[s] = FollowStats{
170
+
Followers: followers,
171
+
Following: following,
172
+
}
173
+
}
73
174
74
-
// Limit the slice to 100 events
75
-
if len(events) > limit {
76
-
events = events[:limit]
175
+
var events []TimelineEvent
176
+
for _, f := range follows {
177
+
profile, _ := profileMap[f.SubjectDid]
178
+
followStatMap, _ := followStatMap[f.SubjectDid]
179
+
180
+
events = append(events, TimelineEvent{
181
+
Follow: &f,
182
+
Profile: &profile,
183
+
FollowStats: &followStatMap,
184
+
EventAt: f.FollowedAt,
185
+
})
77
186
}
78
187
79
188
return events, nil
+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
14
"tangled.sh/tangled.sh/core/api/tangled"
15
15
"tangled.sh/tangled.sh/core/appview/config"
16
16
"tangled.sh/tangled.sh/core/appview/db"
17
-
"tangled.sh/tangled.sh/core/appview/idresolver"
18
17
"tangled.sh/tangled.sh/core/appview/spindleverify"
18
+
"tangled.sh/tangled.sh/core/idresolver"
19
19
"tangled.sh/tangled.sh/core/rbac"
20
20
)
21
21
···
64
64
err = i.ingestSpindleMember(e)
65
65
case tangled.SpindleNSID:
66
66
err = i.ingestSpindle(e)
67
+
case tangled.StringNSID:
68
+
err = i.ingestString(e)
67
69
}
68
70
l = i.Logger.With("nsid", e.Commit.Collection)
69
71
}
···
100
102
l.Error("invalid record", "err", err)
101
103
return err
102
104
}
103
-
err = db.AddStar(i.Db, did, subjectUri, e.Commit.RKey)
105
+
err = db.AddStar(i.Db, &db.Star{
106
+
StarredByDid: did,
107
+
RepoAt: subjectUri,
108
+
Rkey: e.Commit.RKey,
109
+
})
104
110
case models.CommitOperationDelete:
105
111
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
106
112
}
···
129
135
return err
130
136
}
131
137
132
-
subjectDid := record.Subject
133
-
err = db.AddFollow(i.Db, did, subjectDid, e.Commit.RKey)
138
+
err = db.AddFollow(i.Db, &db.Follow{
139
+
UserDid: did,
140
+
SubjectDid: record.Subject,
141
+
Rkey: e.Commit.RKey,
142
+
})
134
143
case models.CommitOperationDelete:
135
144
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
136
145
}
···
503
512
i.Enforcer.E.LoadPolicy()
504
513
}()
505
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
+
506
525
err = db.DeleteSpindle(
507
526
tx,
508
527
db.FilterEq("owner", did),
···
532
551
533
552
return nil
534
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
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
15
lexutil "github.com/bluesky-social/indigo/lex/util"
16
16
"github.com/go-chi/chi/v5"
17
-
"github.com/posthog/posthog-go"
18
17
19
18
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview"
21
19
"tangled.sh/tangled.sh/core/appview/config"
22
20
"tangled.sh/tangled.sh/core/appview/db"
23
-
"tangled.sh/tangled.sh/core/appview/idresolver"
21
+
"tangled.sh/tangled.sh/core/appview/notify"
24
22
"tangled.sh/tangled.sh/core/appview/oauth"
25
23
"tangled.sh/tangled.sh/core/appview/pages"
26
24
"tangled.sh/tangled.sh/core/appview/pagination"
27
25
"tangled.sh/tangled.sh/core/appview/reporesolver"
26
+
"tangled.sh/tangled.sh/core/idresolver"
27
+
"tangled.sh/tangled.sh/core/tid"
28
28
)
29
29
30
30
type Issues struct {
···
34
34
idResolver *idresolver.Resolver
35
35
db *db.DB
36
36
config *config.Config
37
-
posthog posthog.Client
37
+
notifier notify.Notifier
38
38
}
39
39
40
40
func New(
···
44
44
idResolver *idresolver.Resolver,
45
45
db *db.DB,
46
46
config *config.Config,
47
-
posthog posthog.Client,
47
+
notifier notify.Notifier,
48
48
) *Issues {
49
49
return &Issues{
50
50
oauth: oauth,
···
53
53
idResolver: idResolver,
54
54
db: db,
55
55
config: config,
56
-
posthog: posthog,
56
+
notifier: notifier,
57
57
}
58
58
}
59
59
···
120
120
DidHandleMap: didHandleMap,
121
121
122
122
OrderedReactionKinds: db.OrderedReactionKinds,
123
-
Reactions: reactionCountMap,
124
-
UserReacted: userReactions,
123
+
Reactions: reactionCountMap,
124
+
UserReacted: userReactions,
125
125
})
126
126
127
127
}
···
171
171
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
172
172
Collection: tangled.RepoIssueStateNSID,
173
173
Repo: user.Did,
174
-
Rkey: appview.TID(),
174
+
Rkey: tid.TID(),
175
175
Record: &lexutil.LexiconTypeDecoder{
176
176
Val: &tangled.RepoIssueState{
177
177
Issue: issue.IssueAt,
···
275
275
}
276
276
277
277
commentId := mathrand.IntN(1000000)
278
-
rkey := appview.TID()
278
+
rkey := tid.TID()
279
279
280
280
err := db.NewIssueComment(rp.db, &db.Comment{
281
281
OwnerDid: user.Did,
···
703
703
return
704
704
}
705
705
706
-
err = db.NewIssue(tx, &db.Issue{
706
+
issue := &db.Issue{
707
707
RepoAt: f.RepoAt,
708
708
Title: title,
709
709
Body: body,
710
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
711
}
717
-
718
-
issueId, err := db.GetIssueId(rp.db, f.RepoAt)
712
+
err = db.NewIssue(tx, issue)
719
713
if err != nil {
720
-
log.Println("failed to get issue id", err)
714
+
log.Println("failed to create issue", err)
721
715
rp.pages.Notice(w, "issues", "Failed to create issue.")
722
716
return
723
717
}
···
732
726
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
733
727
Collection: tangled.RepoIssueNSID,
734
728
Repo: user.Did,
735
-
Rkey: appview.TID(),
729
+
Rkey: tid.TID(),
736
730
Record: &lexutil.LexiconTypeDecoder{
737
731
Val: &tangled.RepoIssue{
738
732
Repo: atUri,
739
733
Title: title,
740
734
Body: &body,
741
735
Owner: user.Did,
742
-
IssueId: int64(issueId),
736
+
IssueId: int64(issue.IssueId),
743
737
},
744
738
},
745
739
})
···
749
743
return
750
744
}
751
745
752
-
err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri)
746
+
err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri)
753
747
if err != nil {
754
748
log.Println("failed to set issue at", err)
755
749
rp.pages.Notice(w, "issues", "Failed to create issue.")
756
750
return
757
751
}
758
752
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
-
}
753
+
rp.notifier.NewIssue(r.Context(), issue)
769
754
770
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
755
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
771
756
return
772
757
}
773
758
}
+3
-4
appview/knots/knots.go
+3
-4
appview/knots/knots.go
···
13
13
14
14
"github.com/go-chi/chi/v5"
15
15
"tangled.sh/tangled.sh/core/api/tangled"
16
-
"tangled.sh/tangled.sh/core/appview"
17
16
"tangled.sh/tangled.sh/core/appview/config"
18
17
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/idresolver"
20
18
"tangled.sh/tangled.sh/core/appview/middleware"
21
19
"tangled.sh/tangled.sh/core/appview/oauth"
22
20
"tangled.sh/tangled.sh/core/appview/pages"
23
21
"tangled.sh/tangled.sh/core/eventconsumer"
22
+
"tangled.sh/tangled.sh/core/idresolver"
24
23
"tangled.sh/tangled.sh/core/knotclient"
25
24
"tangled.sh/tangled.sh/core/rbac"
25
+
"tangled.sh/tangled.sh/core/tid"
26
26
27
27
comatproto "github.com/bluesky-social/indigo/api/atproto"
28
28
lexutil "github.com/bluesky-social/indigo/lex/util"
···
378
378
}
379
379
380
380
w.Write([]byte(strings.Join(memberDids, "\n")))
381
-
return
382
381
}
383
382
384
383
// add member to domain, requires auth and requires invite access
···
436
435
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
437
436
Collection: tangled.KnotMemberNSID,
438
437
Repo: currentUser.Did,
439
-
Rkey: appview.TID(),
438
+
Rkey: tid.TID(),
440
439
Record: &lexutil.LexiconTypeDecoder{
441
440
Val: &tangled.KnotMember{
442
441
Subject: subjectIdentity.DID.String(),
+3
-11
appview/middleware/middleware.go
+3
-11
appview/middleware/middleware.go
···
13
13
"github.com/bluesky-social/indigo/atproto/identity"
14
14
"github.com/go-chi/chi/v5"
15
15
"tangled.sh/tangled.sh/core/appview/db"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
17
16
"tangled.sh/tangled.sh/core/appview/oauth"
18
17
"tangled.sh/tangled.sh/core/appview/pages"
19
18
"tangled.sh/tangled.sh/core/appview/pagination"
20
19
"tangled.sh/tangled.sh/core/appview/reporesolver"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
21
"tangled.sh/tangled.sh/core/rbac"
22
22
)
23
23
···
167
167
}
168
168
}
169
169
170
-
func StripLeadingAt(next http.Handler) http.Handler {
171
-
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
172
-
path := req.URL.EscapedPath()
173
-
if strings.HasPrefix(path, "/@") {
174
-
req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@")
175
-
}
176
-
next.ServeHTTP(w, req)
177
-
})
178
-
}
179
-
180
170
func (mw Middleware) ResolveIdent() middlewareFunc {
181
171
excluded := []string{"favicon.ico"}
182
172
···
187
177
next.ServeHTTP(w, req)
188
178
return
189
179
}
180
+
181
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
190
182
191
183
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
192
184
if err != nil {
+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
16
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
17
17
"tangled.sh/tangled.sh/core/appview/config"
18
18
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/idresolver"
20
19
"tangled.sh/tangled.sh/core/appview/middleware"
21
20
"tangled.sh/tangled.sh/core/appview/oauth"
22
21
"tangled.sh/tangled.sh/core/appview/oauth/client"
23
22
"tangled.sh/tangled.sh/core/appview/pages"
23
+
"tangled.sh/tangled.sh/core/idresolver"
24
24
"tangled.sh/tangled.sh/core/knotclient"
25
25
"tangled.sh/tangled.sh/core/rbac"
26
26
)
+73
appview/oauth/oauth.go
+73
appview/oauth/oauth.go
···
7
7
"net/url"
8
8
"time"
9
9
10
+
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
10
11
"github.com/gorilla/sessions"
11
12
oauth "tangled.sh/icyphox.sh/atproto-oauth"
12
13
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
···
204
205
})
205
206
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
207
280
}
208
281
209
282
type ClientMetadata struct {
+16
-5
appview/pages/funcmap.go
+16
-5
appview/pages/funcmap.go
···
191
191
if v.Len() == 0 {
192
192
return nil
193
193
}
194
-
return v.Slice(0, min(n, v.Len()-1)).Interface()
194
+
return v.Slice(0, min(n, v.Len())).Interface()
195
195
},
196
196
197
197
"markdown": func(text string) template.HTML {
···
241
241
return u
242
242
},
243
243
244
-
"tinyAvatar": p.tinyAvatar,
245
-
"langColor": enry.GetColor,
244
+
"tinyAvatar": func(handle string) string {
245
+
return p.avatarUri(handle, "tiny")
246
+
},
247
+
"fullAvatar": func(handle string) string {
248
+
return p.avatarUri(handle, "")
249
+
},
250
+
"langColor": enry.GetColor,
246
251
"layoutSide": func() string {
247
252
return "col-span-1 md:col-span-2 lg:col-span-3"
248
253
},
···
252
257
}
253
258
}
254
259
255
-
func (p *Pages) tinyAvatar(handle string) string {
260
+
func (p *Pages) avatarUri(handle, size string) string {
256
261
handle = strings.TrimPrefix(handle, "@")
262
+
257
263
secret := p.avatar.SharedSecret
258
264
h := hmac.New(sha256.New, []byte(secret))
259
265
h.Write([]byte(handle))
260
266
signature := hex.EncodeToString(h.Sum(nil))
261
-
return fmt.Sprintf("%s/%s/%s?size=tiny", p.avatar.Host, signature, handle)
267
+
268
+
sizeArg := ""
269
+
if size != "" {
270
+
sizeArg = fmt.Sprintf("size=%s", size)
271
+
}
272
+
return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg)
262
273
}
263
274
264
275
func icon(name string, classes []string) (template.HTML, error) {
+2
-2
appview/pages/markup/camo.go
+2
-2
appview/pages/markup/camo.go
···
9
9
"github.com/yuin/goldmark/ast"
10
10
)
11
11
12
-
func generateCamoURL(baseURL, secret, imageURL string) string {
12
+
func GenerateCamoURL(baseURL, secret, imageURL string) string {
13
13
h := hmac.New(sha256.New, []byte(secret))
14
14
h.Write([]byte(imageURL))
15
15
signature := hex.EncodeToString(h.Sum(nil))
···
24
24
}
25
25
26
26
if rctx.CamoUrl != "" && rctx.CamoSecret != "" {
27
-
return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst)
27
+
return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst)
28
28
}
29
29
30
30
return dst
+156
-10
appview/pages/pages.go
+156
-10
appview/pages/pages.go
···
16
16
"strings"
17
17
"sync"
18
18
19
+
"tangled.sh/tangled.sh/core/api/tangled"
19
20
"tangled.sh/tangled.sh/core/appview/commitverify"
20
21
"tangled.sh/tangled.sh/core/appview/config"
21
22
"tangled.sh/tangled.sh/core/appview/db"
···
30
31
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
31
32
"github.com/alecthomas/chroma/v2/lexers"
32
33
"github.com/alecthomas/chroma/v2/styles"
34
+
"github.com/bluesky-social/indigo/atproto/identity"
33
35
"github.com/bluesky-social/indigo/atproto/syntax"
34
36
"github.com/go-git/go-git/v5/plumbing"
35
37
"github.com/go-git/go-git/v5/plumbing/object"
36
-
"github.com/microcosm-cc/bluemonday"
37
38
)
38
39
39
40
//go:embed templates/* static
···
262
263
return p.executePlain("user/login", w, params)
263
264
}
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
+
265
290
type TimelineParams struct {
266
291
LoggedInUser *oauth.User
267
292
Timeline []db.TimelineEvent
···
391
416
UserDid string
392
417
UserHandle string
393
418
FollowStatus db.FollowStatus
394
-
AvatarUri string
395
419
Followers int
396
420
Following int
397
421
···
448
472
return p.executePlain("user/fragments/editPins", w, params)
449
473
}
450
474
451
-
type RepoActionsFragmentParams struct {
475
+
type RepoStarFragmentParams struct {
452
476
IsStarred bool
453
477
RepoAt syntax.ATURI
454
478
Stats db.RepoStats
455
479
}
456
480
457
-
func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error {
458
-
return p.executePlain("repo/fragments/repoActions", w, params)
481
+
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
482
+
return p.executePlain("repo/fragments/repoStar", w, params)
459
483
}
460
484
461
485
type RepoDescriptionParams struct {
···
502
526
ext := filepath.Ext(params.ReadmeFileName)
503
527
switch ext {
504
528
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
529
+
htmlString = p.rctx.Sanitize(htmlString)
505
530
htmlString = p.rctx.RenderMarkdown(params.Readme)
506
531
params.Raw = false
507
-
params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString))
532
+
params.HTMLReadme = template.HTML(htmlString)
508
533
default:
509
-
htmlString = string(params.Readme)
510
534
params.Raw = true
511
-
params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
512
535
}
513
536
}
514
537
···
555
578
RepoInfo repoinfo.RepoInfo
556
579
Active string
557
580
BreadCrumbs [][]string
558
-
BaseTreeLink string
559
-
BaseBlobLink string
581
+
TreePath string
560
582
types.RepoTreeResponse
561
583
}
562
584
···
626
648
LoggedInUser *oauth.User
627
649
RepoInfo repoinfo.RepoInfo
628
650
Active string
651
+
Unsupported bool
652
+
IsImage bool
653
+
IsVideo bool
654
+
ContentSrc string
629
655
BreadCrumbs [][]string
630
656
ShowRendered bool
631
657
RenderToggle bool
···
693
719
Branches []types.Branch
694
720
Spindles []string
695
721
CurrentSpindle string
722
+
Secrets []*tangled.RepoListSecrets_Secret
723
+
696
724
// TODO: use repoinfo.roles
697
725
IsCollaboratorInviteAllowed bool
698
726
}
···
702
730
return p.executeRepo("repo/settings", w, params)
703
731
}
704
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
+
705
777
type RepoIssuesParams struct {
706
778
LoggedInUser *oauth.User
707
779
RepoInfo repoinfo.RepoInfo
···
813
885
DidHandleMap map[string]string
814
886
FilteringBy db.PullState
815
887
Stacks map[string]db.Stack
888
+
Pipelines map[string]db.Pipeline
816
889
}
817
890
818
891
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1069
1142
func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
1070
1143
params.Active = "pipelines"
1071
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)
1072
1218
}
1073
1219
1074
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
13
<div
14
14
id="add-member-{{ .Id }}"
15
15
popover
16
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white">
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
17
{{ block "addKnotMemberPopover" . }} {{ end }}
18
18
</div>
19
19
{{ end }}
+22
-45
appview/pages/templates/layouts/base.html
+22
-45
appview/pages/templates/layouts/base.html
···
14
14
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
15
15
{{ block "extrameta" . }}{{ end }}
16
16
</head>
17
-
<body class="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
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
18
{{ block "topbarLayout" . }}
19
-
<div class="px-1 grid grid-cols-1 md:grid-cols-12 gap-2">
20
-
<div class="{{ layoutSide }}">
21
-
{{ block "topbarLeft" . }} {{ end }}
22
-
</div>
23
-
<header style="z-index: 20" class="{{ layoutCenter }}">
24
-
{{ block "topbar" . }}
25
-
{{ template "layouts/topbar" . }}
26
-
{{ end }}
27
-
</header>
28
-
<div class="{{ layoutSide }}">
29
-
{{ block "topbarRight" . }} {{ end }}
30
-
</div>
31
-
</div>
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>
32
22
{{ end }}
33
23
34
-
<div class="flex flex-col min-h-screen">
35
-
{{ block "contentLayout" . }}
36
-
<div class="px-1 grid grid-cols-1 md:grid-cols-12 gap-2">
37
-
<div class="{{ layoutSide }}">
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">
38
28
{{ block "contentLeft" . }} {{ end }}
39
29
</div>
40
-
<main class="{{ layoutCenter }}">
30
+
<main class="col-span-1 md:col-span-8">
41
31
{{ block "content" . }}{{ end }}
42
32
</main>
43
-
<div class="{{ layoutSide }}">
33
+
<div class="col-span-1 md:col-span-2">
44
34
{{ block "contentRight" . }} {{ end }}
45
35
</div>
46
-
</div>
47
-
{{ end }}
48
-
49
-
{{ block "contentAfterLayout" . }}
50
-
<div class="px-1 grid grid-cols-1 md:grid-cols-12 gap-2">
51
-
<div class="{{ layoutSide }}">
36
+
{{ end }}
37
+
38
+
{{ block "contentAfterLayout" . }}
39
+
<div class="col-span-1 md:col-span-2">
52
40
{{ block "contentAfterLeft" . }} {{ end }}
53
41
</div>
54
-
<main class="px-1 {{ layoutCenter }}">
42
+
<main class="col-span-1 md:col-span-8">
55
43
{{ block "contentAfter" . }}{{ end }}
56
44
</main>
57
-
<div class="{{ layoutSide }}">
45
+
<div class="col-span-1 md:col-span-2">
58
46
{{ block "contentAfterRight" . }} {{ end }}
59
47
</div>
60
-
</div>
61
-
{{ end }}
62
-
</div>
48
+
{{ end }}
49
+
</div>
50
+
{{ end }}
63
51
64
52
{{ block "footerLayout" . }}
65
-
<div class="px-1 grid grid-cols-1 md:grid-cols-12 gap-2">
66
-
<div class="{{ layoutSide }}">
67
-
{{ block "footerLeft" . }} {{ end }}
68
-
</div>
69
-
<footer class="mt-16 {{ layoutCenter }}">
70
-
{{ block "footer" . }}
71
-
{{ template "layouts/footer" . }}
72
-
{{ end }}
73
-
</footer>
74
-
<div class="{{ layoutSide }}">
75
-
{{ block "footerRight" . }} {{ end }}
76
-
</div>
77
-
</div>
53
+
<footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12">
54
+
{{ template "layouts/footer" . }}
55
+
</footer>
78
56
{{ end }}
79
-
80
57
</body>
81
58
</html>
82
59
{{ end }}
+23
-1
appview/pages/templates/layouts/repobase.html
+23
-1
appview/pages/templates/layouts/repobase.html
···
19
19
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
20
20
</div>
21
21
22
-
{{ template "repo/fragments/repoActions" .RepoInfo }}
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>
23
45
</div>
24
46
{{ template "repo/fragments/repoDescription" . }}
25
47
</section>
+27
-17
appview/pages/templates/layouts/topbar.html
+27
-17
appview/pages/templates/layouts/topbar.html
···
1
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">
2
+
<nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
3
<div class="flex justify-between p-0 items-center">
4
4
<div id="left-items">
5
5
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
6
6
tangled<sub>alpha</sub>
7
7
</a>
8
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
9
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">
10
+
<div id="right-items" class="flex items-center gap-2">
23
11
{{ 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>
12
+
{{ block "newButton" . }} {{ end }}
27
13
{{ block "dropDown" . }} {{ end }}
28
14
{{ else }}
29
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>
30
20
{{ end }}
31
21
</div>
32
22
</div>
33
23
</nav>
34
24
{{ end }}
35
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
+
36
44
{{ define "dropDown" }}
37
45
<details class="relative inline-block text-left">
38
46
<summary
···
45
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"
46
54
>
47
55
<a href="/{{ $user }}">profile</a>
56
+
<a href="/{{ $user }}?tab=repos">repositories</a>
57
+
<a href="/strings/{{ $user }}">strings</a>
48
58
<a href="/knots">knots</a>
49
59
<a href="/spindles">spindles</a>
50
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
5
6
6
{{ $title := printf "%s at %s · %s" .Path .Ref .RepoInfo.FullName }}
7
7
{{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
8
-
8
+
9
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
-
10
+
11
11
{{ end }}
12
12
13
13
{{ define "repoContent" }}
···
44
44
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
45
45
{{ if .RenderToggle }}
46
46
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
47
-
<a
48
-
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
47
+
<a
48
+
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
49
49
hx-boost="true"
50
50
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
51
51
{{ end }}
52
52
</div>
53
53
</div>
54
54
</div>
55
-
{{ if .IsBinary }}
55
+
{{ if and .IsBinary .Unsupported }}
56
56
<p class="text-center text-gray-400 dark:text-gray-500">
57
-
This is a binary file and will not be displayed.
57
+
Previews are not supported for this file type.
58
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>
59
72
{{ else }}
60
73
<div class="overflow-auto relative">
61
74
{{ if .ShowRendered }}
+22
-30
appview/pages/templates/repo/commit.html
+22
-30
appview/pages/templates/repo/commit.html
···
80
80
{{end}}
81
81
82
82
{{ define "topbarLayout" }}
83
-
<div class="px-1 grid grid-cols-1">
84
-
<header style="z-index: 20" class="col-span-1">
85
-
{{ block "topbar" . }}
86
-
{{ template "layouts/topbar" . }}
87
-
{{ end }}
88
-
</header>
89
-
</div>
83
+
<header class="px-1 col-span-full" style="z-index: 20;">
84
+
{{ template "layouts/topbar" . }}
85
+
</header>
90
86
{{ end }}
91
87
92
-
{{ define "contentLayout" }}
93
-
<div class="grid grid-cols-1 md:grid-cols-1 gap-2">
94
-
<main class="px-1 col-span-1">
88
+
{{ define "mainLayout" }}
89
+
<div class="px-1 col-span-full flex flex-col gap-4">
90
+
{{ block "contentLayout" . }}
95
91
{{ block "content" . }}{{ end }}
96
-
</main>
97
-
</div>
98
-
{{ end }}
92
+
{{ end }}
99
93
100
-
{{ block "contentAfterLayout" . }}
101
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-2">
102
-
<div class="col-span-1 md:col-span-2">
103
-
{{ block "contentAfterLeft" . }} {{ end }}
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>
104
102
</div>
105
-
<main class="px-1 col-span-1 md:col-span-10">
106
-
{{ block "contentAfter" . }}{{ end }}
107
-
</main>
103
+
{{ end }}
108
104
</div>
109
105
{{ end }}
110
106
111
-
{{ block "footerLayout" . }}
112
-
<div class="px-1 grid grid-cols-1">
113
-
<footer class="mt-16 col-span-1">
114
-
{{ block "footer" . }}
115
-
{{ template "layouts/footer" . }}
116
-
{{ end }}
117
-
</footer>
118
-
</div>
107
+
{{ define "footerLayout" }}
108
+
<footer class="px-1 col-span-full mt-12">
109
+
{{ template "layouts/footer" . }}
110
+
</footer>
119
111
{{ end }}
120
112
121
113
{{ define "contentAfter" }}
···
123
115
{{end}}
124
116
125
117
{{ define "contentAfterLeft" }}
126
-
<div class="flex flex-col gap-4 col-span-1 md:col-span-2 mt-4">
118
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
127
119
{{ template "repo/fragments/diffOpts" .DiffOpts }}
128
120
</div>
129
-
<div class="sticky top-0 mt-4">
121
+
<div class="sticky top-0 flex-grow max-h-screen">
130
122
{{ template "repo/fragments/diffChangedFiles" .Diff }}
131
123
</div>
132
124
{{end}}
+22
-30
appview/pages/templates/repo/compare/compare.html
+22
-30
appview/pages/templates/repo/compare/compare.html
···
11
11
{{ end }}
12
12
13
13
{{ define "topbarLayout" }}
14
-
<div class="px-1 grid grid-cols-1">
15
-
<header style="z-index: 20" class="col-span-1">
16
-
{{ block "topbar" . }}
17
-
{{ template "layouts/topbar" . }}
18
-
{{ end }}
19
-
</header>
20
-
</div>
14
+
<header class="px-1 col-span-full" style="z-index: 20;">
15
+
{{ template "layouts/topbar" . }}
16
+
</header>
21
17
{{ end }}
22
18
23
-
{{ define "contentLayout" }}
24
-
<div class="grid grid-cols-1 md:grid-cols-1 gap-2">
25
-
<main class="px-1 col-span-1">
19
+
{{ define "mainLayout" }}
20
+
<div class="px-1 col-span-full flex flex-col gap-4">
21
+
{{ block "contentLayout" . }}
26
22
{{ block "content" . }}{{ end }}
27
-
</main>
28
-
</div>
29
-
{{ end }}
23
+
{{ end }}
30
24
31
-
{{ block "contentAfterLayout" . }}
32
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-2">
33
-
<div class="col-span-1 md:col-span-2">
34
-
{{ block "contentAfterLeft" . }} {{ end }}
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>
35
33
</div>
36
-
<main class="px-1 col-span-1 md:col-span-10">
37
-
{{ block "contentAfter" . }}{{ end }}
38
-
</main>
34
+
{{ end }}
39
35
</div>
40
36
{{ end }}
41
37
42
-
{{ block "footerLayout" . }}
43
-
<div class="px-1 grid grid-cols-1">
44
-
<footer class="mt-16 col-span-1">
45
-
{{ block "footer" . }}
46
-
{{ template "layouts/footer" . }}
47
-
{{ end }}
48
-
</footer>
49
-
</div>
38
+
{{ define "footerLayout" }}
39
+
<footer class="px-1 col-span-full mt-12">
40
+
{{ template "layouts/footer" . }}
41
+
</footer>
50
42
{{ end }}
51
43
52
44
{{ define "contentAfter" }}
···
54
46
{{end}}
55
47
56
48
{{ define "contentAfterLeft" }}
57
-
<div class="flex flex-col gap-4 col-span-1 md:col-span-2 mt-4">
49
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
58
50
{{ template "repo/fragments/diffOpts" .DiffOpts }}
59
51
</div>
60
-
<div class="sticky top-0 mt-4">
52
+
<div class="sticky top-0 flex-grow max-h-screen">
61
53
{{ template "repo/fragments/diffChangedFiles" .Diff }}
62
54
</div>
63
55
{{end}}
+15
-3
appview/pages/templates/repo/empty.html
+15
-3
appview/pages/templates/repo/empty.html
···
23
23
{{ end }}
24
24
</div>
25
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>
26
40
{{ else }}
27
-
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
28
-
This is an empty repository. Push some commits here.
29
-
</p>
41
+
<p class="text-gray-400 dark:text-gray-500 py-6 text-center">This is an empty repository.</p>
30
42
{{ end }}
31
43
</main>
32
44
{{ end }}
+12
-10
appview/pages/templates/repo/fragments/diff.html
+12
-10
appview/pages/templates/repo/fragments/diff.html
···
1
1
{{ define "repo/fragments/diff" }}
2
-
{{ $repo := index . 0 }}
3
-
{{ $diff := index . 1 }}
4
-
{{ $opts := index . 2 }}
2
+
{{ $repo := index . 0 }}
3
+
{{ $diff := index . 1 }}
4
+
{{ $opts := index . 2 }}
5
5
6
-
{{ $commit := $diff.Commit }}
7
-
{{ $diff := $diff.Diff }}
8
-
{{ $isSplit := $opts.Split }}
9
-
{{ $this := $commit.This }}
10
-
{{ $parent := $commit.Parent }}
6
+
{{ $commit := $diff.Commit }}
7
+
{{ $diff := $diff.Diff }}
8
+
{{ $isSplit := $opts.Split }}
9
+
{{ $this := $commit.This }}
10
+
{{ $parent := $commit.Parent }}
11
+
{{ $last := sub (len $diff) 1 }}
11
12
12
-
{{ $last := sub (len $diff) 1 }}
13
+
<div class="flex flex-col gap-4">
13
14
{{ range $idx, $hunk := $diff }}
14
15
{{ with $hunk }}
15
-
<section class="mt-4 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
16
+
<section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
16
17
<div id="file-{{ .Name.New }}">
17
18
<div id="diff-file">
18
19
<details open>
···
103
104
</section>
104
105
{{ end }}
105
106
{{ end }}
107
+
</div>
106
108
{{ end }}
+1
-1
appview/pages/templates/repo/fragments/diffChangedFiles.html
+1
-1
appview/pages/templates/repo/fragments/diffChangedFiles.html
···
1
1
{{ define "repo/fragments/diffChangedFiles" }}
2
2
{{ $stat := .Stat }}
3
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">
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
5
<div class="diff-stat">
6
6
<div class="flex gap-2 items-center">
7
7
<strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
+3
-1
appview/pages/templates/repo/fragments/interdiff.html
+3
-1
appview/pages/templates/repo/fragments/interdiff.html
···
7
7
{{ $last := sub (len $diff) 1 }}
8
8
{{ $isSplit := $opts.Split }}
9
9
10
+
<div class="flex flex-col gap-4">
10
11
{{ range $idx, $hunk := $diff }}
11
12
{{ with $hunk }}
12
-
<section class="mt-4 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
13
+
<section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
13
14
<div id="file-{{ .Name }}">
14
15
<div id="diff-file">
15
16
<details {{ if not (.Status.IsOnlyInOne) }}open{{end}}>
···
86
87
</section>
87
88
{{ end }}
88
89
{{ end }}
90
+
</div>
89
91
{{ end }}
90
92
+1
-1
appview/pages/templates/repo/fragments/interdiffFiles.html
+1
-1
appview/pages/templates/repo/fragments/interdiffFiles.html
···
1
1
{{ define "repo/fragments/interdiffFiles" }}
2
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">
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
4
<div class="diff-stat">
5
5
<div class="flex gap-2 items-center">
6
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 }}
+39
-71
appview/pages/templates/repo/index.html
+39
-71
appview/pages/templates/repo/index.html
···
127
127
{{ end }}
128
128
129
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" }}
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" }}
136
132
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>
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" }}
150
139
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>
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>
171
151
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>
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>
180
160
{{ end }}
181
161
182
162
{{ define "rightInfo" }}
···
190
170
{{ define "commitLog" }}
191
171
<div id="commit-log" class="md:col-span-1 px-2 pb-4">
192
172
<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>
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>
200
176
</a>
201
177
</div>
202
178
<div class="flex flex-col gap-6">
···
298
274
{{ define "branchList" }}
299
275
{{ if gt (len .BranchesTrunc) 0 }}
300
276
<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>
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>
308
280
</a>
309
281
<div class="flex flex-col gap-1">
310
282
{{ range .BranchesTrunc }}
···
341
313
{{ if gt (len .TagsTrunc) 0 }}
342
314
<div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
343
315
<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>
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>
351
319
</a>
352
320
</div>
353
321
<div class="flex flex-col gap-1">
···
378
346
{{ end }}
379
347
380
348
{{ define "repoAfter" }}
381
-
{{- if .HTMLReadme -}}
349
+
{{- if or .HTMLReadme .Readme -}}
382
350
<section
383
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 }}
384
352
prose dark:prose-invert dark:[&_pre]:bg-gray-900
···
386
354
dark:[&_pre]:border dark:[&_pre]:border-gray-700
387
355
{{ end }}"
388
356
>
389
-
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-scroll">
390
-
{{- .HTMLReadme -}}
357
+
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
358
+
{{- .Readme -}}
391
359
</pre>
392
360
{{- else -}}
393
361
{{ .HTMLReadme }}
+2
-4
appview/pages/templates/repo/issues/fragments/editIssueComment.html
+2
-4
appview/pages/templates/repo/issues/fragments/editIssueComment.html
···
1
1
{{ define "repo/issues/fragments/editIssueComment" }}
2
2
{{ with .Comment }}
3
3
<div id="comment-container-{{.CommentId}}">
4
-
<div class="flex items-center gap-2 mb-2 text-gray-500 text-sm">
4
+
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
5
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
6
6
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
7
7
···
9
9
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
10
10
{{ if $isIssueAuthor }}
11
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
12
author
14
-
</span>
15
13
{{ end }}
16
14
17
15
<span class="before:content-['ยท']"></span>
18
16
<a
19
17
href="#{{ .CommentId }}"
20
-
class="text-gray-500 hover:text-gray-500 hover:underline no-underline"
18
+
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
21
19
id="{{ .CommentId }}">
22
20
{{ template "repo/fragments/time" .Created }}
23
21
</a>
+7
-8
appview/pages/templates/repo/issues/fragments/issueComment.html
+7
-8
appview/pages/templates/repo/issues/fragments/issueComment.html
···
5
5
{{ $owner := index $.DidHandleMap .OwnerDid }}
6
6
{{ template "user/fragments/picHandleLink" $owner }}
7
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
+
8
15
<span class="before:content-['ยท']"></span>
9
16
<a
10
17
href="#{{ .CommentId }}"
···
18
25
{{ template "repo/fragments/time" .Created }}
19
26
{{ end }}
20
27
</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
28
30
29
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
31
30
{{ if and $isCommentOwner (not .Deleted) }}
+72
-75
appview/pages/templates/repo/log.html
+72
-75
appview/pages/templates/repo/log.html
···
14
14
</h2>
15
15
16
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>
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>
61
59
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 }}
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 }}
69
67
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>
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>
78
76
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>
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>
95
92
96
93
<!-- mobile view (visible only on small screens) -->
97
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
13
</span>
14
14
</div>
15
15
16
-
<div class="flex-shrink-0 flex items-center">
16
+
<div class="flex-shrink-0 flex items-center gap-2">
17
17
{{ $latestRound := .LastRoundNumber }}
18
18
{{ $lastSubmission := index .Submissions $latestRound }}
19
19
{{ $commentCount := len $lastSubmission.Comments }}
20
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>
21
+
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
22
+
<span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span>
25
23
{{ end }}
26
24
<span>
27
-
<div class="inline-flex items-center gap-2">
25
+
<div class="inline-flex items-center gap-1">
28
26
{{ i "message-square" "w-3 h-3 md:hidden" }}
29
27
{{ $commentCount }}
30
28
<span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span>
31
29
</div>
32
30
</span>
33
-
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
31
+
<span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span>
34
32
<span>
35
33
<span class="hidden md:inline">round</span>
36
34
<span class="font-mono">#{{ $latestRound }}</span>
+23
-30
appview/pages/templates/repo/pulls/interdiff.html
+23
-30
appview/pages/templates/repo/pulls/interdiff.html
···
29
29
{{ end }}
30
30
31
31
{{ define "topbarLayout" }}
32
-
<div class="px-1 grid grid-cols-1">
33
-
<header style="z-index: 20" class="col-span-1">
34
-
{{ block "topbar" . }}
35
-
{{ template "layouts/topbar" . }}
36
-
{{ end }}
37
-
</header>
38
-
</div>
32
+
<header class="px-1 col-span-full" style="z-index: 20;">
33
+
{{ template "layouts/topbar" . }}
34
+
</header>
39
35
{{ end }}
40
36
41
-
{{ define "contentLayout" }}
42
-
<div class="grid grid-cols-1 md:grid-cols-1 gap-2">
43
-
<main class="px-1 col-span-1">
37
+
{{ define "mainLayout" }}
38
+
<div class="px-1 col-span-full flex flex-col gap-4">
39
+
{{ block "contentLayout" . }}
44
40
{{ block "content" . }}{{ end }}
45
-
</main>
46
-
</div>
47
-
{{ end }}
41
+
{{ end }}
48
42
49
-
{{ block "contentAfterLayout" . }}
50
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-2">
51
-
<div class="col-span-1 md:col-span-2">
52
-
{{ block "contentAfterLeft" . }} {{ end }}
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>
53
51
</div>
54
-
<main class="px-1 col-span-1 md:col-span-10">
55
-
{{ block "contentAfter" . }}{{ end }}
56
-
</main>
52
+
{{ end }}
57
53
</div>
58
54
{{ end }}
59
55
60
-
{{ block "footerLayout" . }}
61
-
<div class="px-1 grid grid-cols-1">
62
-
<footer class="mt-16 col-span-1">
63
-
{{ block "footer" . }}
64
-
{{ template "layouts/footer" . }}
65
-
{{ end }}
66
-
</footer>
67
-
</div>
56
+
{{ define "footerLayout" }}
57
+
<footer class="px-1 col-span-full mt-12">
58
+
{{ template "layouts/footer" . }}
59
+
</footer>
68
60
{{ end }}
61
+
69
62
70
63
{{ define "contentAfter" }}
71
64
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
72
65
{{end}}
73
66
74
67
{{ define "contentAfterLeft" }}
75
-
<div class="flex flex-col gap-4 col-span-1 md:col-span-2 mt-4">
68
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
76
69
{{ template "repo/fragments/diffOpts" .DiffOpts }}
77
70
</div>
78
-
<div class="sticky top-0 mt-4">
71
+
<div class="sticky top-0 flex-grow max-h-screen">
79
72
{{ template "repo/fragments/interdiffFiles" .Interdiff }}
80
73
</div>
81
74
{{end}}
+22
-30
appview/pages/templates/repo/pulls/patch.html
+22
-30
appview/pages/templates/repo/pulls/patch.html
···
35
35
{{ end }}
36
36
37
37
{{ define "topbarLayout" }}
38
-
<div class="px-1 grid grid-cols-1">
39
-
<header style="z-index: 20" class="col-span-1">
40
-
{{ block "topbar" . }}
41
-
{{ template "layouts/topbar" . }}
42
-
{{ end }}
43
-
</header>
44
-
</div>
38
+
<header class="px-1 col-span-full" style="z-index: 20;">
39
+
{{ template "layouts/topbar" . }}
40
+
</header>
45
41
{{ end }}
46
42
47
-
{{ define "contentLayout" }}
48
-
<div class="grid grid-cols-1 md:grid-cols-1 gap-2">
49
-
<main class="px-1 col-span-1">
43
+
{{ define "mainLayout" }}
44
+
<div class="px-1 col-span-full flex flex-col gap-4">
45
+
{{ block "contentLayout" . }}
50
46
{{ block "content" . }}{{ end }}
51
-
</main>
52
-
</div>
53
-
{{ end }}
47
+
{{ end }}
54
48
55
-
{{ block "contentAfterLayout" . }}
56
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-2">
57
-
<div class="col-span-1 md:col-span-2">
58
-
{{ block "contentAfterLeft" . }} {{ end }}
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>
59
57
</div>
60
-
<main class="px-1 col-span-1 md:col-span-10">
61
-
{{ block "contentAfter" . }}{{ end }}
62
-
</main>
58
+
{{ end }}
63
59
</div>
64
60
{{ end }}
65
61
66
-
{{ block "footerLayout" . }}
67
-
<div class="px-1 grid grid-cols-1">
68
-
<footer class="mt-16 col-span-1">
69
-
{{ block "footer" . }}
70
-
{{ template "layouts/footer" . }}
71
-
{{ end }}
72
-
</footer>
73
-
</div>
62
+
{{ define "footerLayout" }}
63
+
<footer class="px-1 col-span-full mt-12">
64
+
{{ template "layouts/footer" . }}
65
+
</footer>
74
66
{{ end }}
75
67
76
68
{{ define "contentAfter" }}
···
78
70
{{end}}
79
71
80
72
{{ define "contentAfterLeft" }}
81
-
<div class="flex flex-col gap-4 col-span-1 md:col-span-2 mt-4">
73
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
82
74
{{ template "repo/fragments/diffOpts" .DiffOpts }}
83
75
</div>
84
-
<div class="sticky top-0 mt-4">
76
+
<div class="sticky top-0 flex-grow max-h-screen">
85
77
{{ template "repo/fragments/diffChangedFiles" .Diff }}
86
78
</div>
87
79
{{end}}
+43
-52
appview/pages/templates/repo/pulls/pulls.html
+43
-52
appview/pages/templates/repo/pulls/pulls.html
···
54
54
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
55
55
</a>
56
56
</div>
57
-
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
57
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
58
58
{{ $owner := index $.DidHandleMap .OwnerDid }}
59
59
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
60
60
{{ $icon := "ban" }}
···
83
83
{{ template "repo/fragments/time" .Created }}
84
84
</span>
85
85
86
+
87
+
{{ $latestRound := .LastRoundNumber }}
88
+
{{ $lastSubmission := index .Submissions $latestRound }}
89
+
86
90
<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
+
{{ $commentCount := len $lastSubmission.Comments }}
92
+
{{ $s := "s" }}
93
+
{{ if eq $commentCount 1 }}
94
+
{{ $s = "" }}
95
+
{{ end }}
96
+
97
+
{{ len $lastSubmission.Comments}} comment{{$s}}
91
98
</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 -}}
99
+
100
+
<span class="before:content-['ยท']">
101
+
round
102
+
<span class="font-mono">
103
+
#{{ .LastRoundNumber }}
104
+
</span>
103
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 }}
104
111
{{ 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>
112
+
</div>
125
113
</div>
126
114
{{ if .StackId }}
127
115
{{ $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>
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 }}
143
133
{{ end }}
144
134
</div>
145
135
{{ end }}
···
151
141
{{ $root := index . 1 }}
152
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">
153
143
{{ range $pull := $list }}
144
+
{{ $pipeline := index $root.Pipelines $pull.LatestSha }}
154
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">
155
146
<div class="flex gap-2 items-center px-6">
156
147
<div class="flex-grow min-w-0 w-full py-2">
157
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull 0) }}
148
+
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
158
149
</div>
159
150
</div>
160
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
1
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
+
2
3
{{ define "repoContent" }}
3
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
4
-
Collaborators
5
-
</header>
4
+
{{ template "collaboratorSettings" . }}
5
+
{{ template "branchSettings" . }}
6
+
{{ template "dangerZone" . }}
7
+
{{ template "spindleSelector" . }}
8
+
{{ template "spindleSecrets" . }}
9
+
{{ end }}
6
10
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>
11
+
{{ define "collaboratorSettings" }}
12
+
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
13
+
Collaborators
14
+
</header>
24
15
25
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
26
-
<form
27
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
28
-
class="group"
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"
29
22
>
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>
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>
49
31
{{ end }}
32
+
</div>
50
33
34
+
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
51
35
<form
52
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default"
53
-
class="mt-6 group"
36
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
37
+
class="group"
54
38
>
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>
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>
115
53
</form>
116
-
{{ end }}
54
+
{{ end }}
55
+
{{ end }}
117
56
118
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
57
+
{{ define "dangerZone" }}
58
+
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
119
59
<form
120
60
hx-confirm="Are you sure you want to delete this repository?"
121
61
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
122
62
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>
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>
135
74
</form>
136
-
{{ end }}
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" />
137
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 }}
138
168
{{ end }}
+26
-34
appview/pages/templates/repo/tree.html
+26
-34
appview/pages/templates/repo/tree.html
···
19
19
{{define "repoContent"}}
20
20
<main>
21
21
<div class="tree">
22
-
{{ $containerstyle := "py-1" }}
23
22
{{ $linkstyle := "no-underline hover:underline" }}
24
23
25
24
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
···
54
53
</div>
55
54
56
55
{{ 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>
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
69
</div>
70
-
{{ end }}
70
+
</a>
71
71
</div>
72
-
</div>
73
-
{{ end }}
74
-
{{ end }}
75
72
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 }}
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 }}
91
83
</div>
92
-
</div>
84
+
</div>
93
85
{{ end }}
94
-
{{ end }}
86
+
95
87
</div>
96
88
</main>
97
89
{{end}}
+1
-1
appview/pages/templates/spindles/fragments/addMemberModal.html
+1
-1
appview/pages/templates/spindles/fragments/addMemberModal.html
···
13
13
<div
14
14
id="add-member-{{ .Instance }}"
15
15
popover
16
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white">
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
17
{{ block "addMemberPopover" . }} {{ end }}
18
18
</div>
19
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 }}
+108
-77
appview/pages/templates/timeline.html
+108
-77
appview/pages/templates/timeline.html
···
34
34
</p>
35
35
36
36
<div class="flex gap-6 items-center">
37
-
<a href="/login" class="no-underline hover:no-underline ">
38
-
<button class="btn flex gap-2 px-4 items-center">
37
+
<a href="/signup" class="no-underline hover:no-underline ">
38
+
<button class="btn-create flex gap-2 px-4 items-center">
39
39
join now {{ i "arrow-right" "size-4" }}
40
40
</button>
41
41
</a>
···
49
49
<p class="text-xl font-bold dark:text-white">Timeline</p>
50
50
</div>
51
51
52
-
<div class="flex flex-col gap-3 relative">
53
-
<div
54
-
class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600"
55
-
></div>
56
-
{{ range .Timeline }}
57
-
<div
58
-
class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit"
59
-
>
60
-
{{ if .Repo }}
61
-
{{ $userHandle := index $.DidHandleMap .Repo.Did }}
62
-
<div class="flex items-center">
63
-
<p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2">
64
-
{{ template "user/fragments/picHandleLink" $userHandle }}
65
-
{{ if .Source }}
66
-
forked
67
-
<a
68
-
href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}"
69
-
class="no-underline hover:underline"
70
-
>
71
-
{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}</a
72
-
>
73
-
to
74
-
<a
75
-
href="/{{ $userHandle }}/{{ .Repo.Name }}"
76
-
class="no-underline hover:underline"
77
-
>{{ .Repo.Name }}</a
78
-
>
79
-
{{ else }}
80
-
created
81
-
<a
82
-
href="/{{ $userHandle }}/{{ .Repo.Name }}"
83
-
class="no-underline hover:underline"
84
-
>{{ .Repo.Name }}</a
85
-
>
86
-
{{ end }}
87
-
<span
88
-
class="text-gray-700 dark:text-gray-400 text-xs"
89
-
>{{ template "repo/fragments/time" .Repo.Created }}</span
90
-
>
91
-
</p>
92
-
</div>
93
-
{{ else if .Follow }}
94
-
{{ $userHandle := index $.DidHandleMap .Follow.UserDid }}
95
-
{{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }}
96
-
<div class="flex items-center">
97
-
<p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2">
98
-
{{ template "user/fragments/picHandleLink" $userHandle }}
99
-
followed
100
-
{{ template "user/fragments/picHandleLink" $subjectHandle }}
101
-
<span
102
-
class="text-gray-700 dark:text-gray-400 text-xs"
103
-
>{{ template "repo/fragments/time" .Follow.FollowedAt }}</span
104
-
>
105
-
</p>
106
-
</div>
107
-
{{ else if .Star }}
108
-
{{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }}
109
-
{{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }}
110
-
<div class="flex items-center">
111
-
<p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2">
112
-
{{ template "user/fragments/picHandleLink" $starrerHandle }}
113
-
starred
114
-
<a
115
-
href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}"
116
-
class="no-underline hover:underline"
117
-
>{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a
118
-
>
119
-
<span
120
-
class="text-gray-700 dark:text-gray-400 text-xs"
121
-
>{{ template "repo/fragments/time" .Star.Created }}</spa
122
-
>
123
-
</p>
124
-
</div>
125
-
{{ end }}
52
+
<div class="flex flex-col gap-4">
53
+
{{ range $i, $e := .Timeline }}
54
+
<div class="relative">
55
+
{{ if ne $i 0 }}
56
+
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
57
+
{{ end }}
58
+
{{ with $e }}
59
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
60
+
{{ if .Repo }}
61
+
{{ block "repoEvent" (list $ .Repo .Source) }} {{ end }}
62
+
{{ else if .Star }}
63
+
{{ block "starEvent" (list $ .Star) }} {{ end }}
64
+
{{ else if .Follow }}
65
+
{{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }}
66
+
{{ end }}
126
67
</div>
127
-
{{ end }}
68
+
{{ end }}
69
+
</div>
70
+
{{ end }}
128
71
</div>
129
72
</div>
130
73
{{ end }}
74
+
75
+
{{ define "repoEvent" }}
76
+
{{ $root := index . 0 }}
77
+
{{ $repo := index . 1 }}
78
+
{{ $source := index . 2 }}
79
+
{{ $userHandle := index $root.DidHandleMap $repo.Did }}
80
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
81
+
{{ template "user/fragments/picHandleLink" $userHandle }}
82
+
{{ with $source }}
83
+
forked
84
+
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}"class="no-underline hover:underline">
85
+
{{ index $root.DidHandleMap .Did }}/{{ .Name }}
86
+
</a>
87
+
to
88
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a>
89
+
{{ else }}
90
+
created
91
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">
92
+
{{ $repo.Name }}
93
+
</a>
94
+
{{ end }}
95
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
96
+
</div>
97
+
{{ with $repo }}
98
+
{{ template "user/fragments/repoCard" (list $root . true) }}
99
+
{{ end }}
100
+
{{ end }}
101
+
102
+
{{ define "starEvent" }}
103
+
{{ $root := index . 0 }}
104
+
{{ $star := index . 1 }}
105
+
{{ with $star }}
106
+
{{ $starrerHandle := index $root.DidHandleMap .StarredByDid }}
107
+
{{ $repoOwnerHandle := index $root.DidHandleMap .Repo.Did }}
108
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
109
+
{{ template "user/fragments/picHandleLink" $starrerHandle }}
110
+
starred
111
+
<a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">
112
+
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
113
+
</a>
114
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
115
+
</div>
116
+
{{ with .Repo }}
117
+
{{ template "user/fragments/repoCard" (list $root . true) }}
118
+
{{ end }}
119
+
{{ end }}
120
+
{{ end }}
121
+
122
+
123
+
{{ define "followEvent" }}
124
+
{{ $root := index . 0 }}
125
+
{{ $follow := index . 1 }}
126
+
{{ $profile := index . 2 }}
127
+
{{ $stat := index . 3 }}
128
+
129
+
{{ $userHandle := index $root.DidHandleMap $follow.UserDid }}
130
+
{{ $subjectHandle := index $root.DidHandleMap $follow.SubjectDid }}
131
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
132
+
{{ template "user/fragments/picHandleLink" $userHandle }}
133
+
followed
134
+
{{ template "user/fragments/picHandleLink" $subjectHandle }}
135
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
136
+
</div>
137
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
138
+
<div class="flex-shrink-0 max-h-full w-24 h-24">
139
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
140
+
</div>
141
+
142
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
143
+
<a href="/{{ $subjectHandle }}">
144
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
145
+
</a>
146
+
{{ with $profile }}
147
+
{{ with .Description }}
148
+
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
149
+
{{ end }}
150
+
{{ end }}
151
+
{{ with $stat }}
152
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
153
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
154
+
<span id="followers">{{ .Followers }} followers</span>
155
+
<span class="select-none after:content-['ยท']"></span>
156
+
<span id="following">{{ .Following }} following</span>
157
+
</div>
158
+
{{ end }}
159
+
</div>
160
+
</div>
161
+
{{ end }}
+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
2
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
3
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
4
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
5
-
{{ if .AvatarUri }}
6
5
<div class="w-3/4 aspect-square relative">
7
-
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" />
6
+
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" />
8
7
</div>
9
-
{{ end }}
10
8
</div>
11
9
<div class="col-span-2">
12
10
<p title="{{ didOrHandle .UserDid .UserHandle }}"
+27
-27
appview/pages/templates/user/fragments/repoCard.html
+27
-27
appview/pages/templates/user/fragments/repoCard.html
···
5
5
6
6
{{ with $repo }}
7
7
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
8
-
<div class="font-medium dark:text-white">
8
+
<div class="font-medium dark:text-white flex gap-2 items-center">
9
9
{{- if $fullName -}}
10
10
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ index $root.DidHandleMap .Did }}/{{ .Name }}</a>
11
11
{{- else -}}
···
26
26
{{ end }}
27
27
28
28
{{ define "repoStats" }}
29
-
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto">
30
-
{{ with .Language }}
31
-
<div class="flex gap-2 items-center text-sm">
32
-
<div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div>
33
-
<span>{{ . }}</span>
34
-
</div>
35
-
{{ end }}
36
-
{{ with .StarCount }}
37
-
<div class="flex gap-1 items-center text-sm">
38
-
{{ i "star" "w-3 h-3 fill-current" }}
39
-
<span>{{ . }}</span>
40
-
</div>
41
-
{{ end }}
42
-
{{ with .IssueCount.Open }}
43
-
<div class="flex gap-1 items-center text-sm">
44
-
{{ i "circle-dot" "w-3 h-3" }}
45
-
<span>{{ . }}</span>
46
-
</div>
47
-
{{ end }}
48
-
{{ with .PullCount.Open }}
49
-
<div class="flex gap-1 items-center text-sm">
50
-
{{ i "git-pull-request" "w-3 h-3" }}
51
-
<span>{{ . }}</span>
52
-
</div>
53
-
{{ end }}
54
-
</div>
29
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto">
30
+
{{ with .Language }}
31
+
<div class="flex gap-2 items-center text-sm">
32
+
<div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div>
33
+
<span>{{ . }}</span>
34
+
</div>
35
+
{{ end }}
36
+
{{ with .StarCount }}
37
+
<div class="flex gap-1 items-center text-sm">
38
+
{{ i "star" "w-3 h-3 fill-current" }}
39
+
<span>{{ . }}</span>
40
+
</div>
41
+
{{ end }}
42
+
{{ with .IssueCount.Open }}
43
+
<div class="flex gap-1 items-center text-sm">
44
+
{{ i "circle-dot" "w-3 h-3" }}
45
+
<span>{{ . }}</span>
46
+
</div>
47
+
{{ end }}
48
+
{{ with .PullCount.Open }}
49
+
<div class="flex gap-1 items-center text-sm">
50
+
{{ i "git-pull-request" "w-3 h-3" }}
51
+
<span>{{ . }}</span>
52
+
</div>
53
+
{{ end }}
54
+
</div>
55
55
{{ end }}
56
56
57
57
+13
-34
appview/pages/templates/user/login.html
+13
-34
appview/pages/templates/user/login.html
···
3
3
<html lang="en" class="dark:bg-gray-900">
4
4
<head>
5
5
<meta charset="UTF-8" />
6
-
<meta
7
-
name="viewport"
8
-
content="width=device-width, initial-scale=1.0"
9
-
/>
10
-
<meta
11
-
property="og:title"
12
-
content="login ยท tangled"
13
-
/>
14
-
<meta
15
-
property="og:url"
16
-
content="https://tangled.sh/login"
17
-
/>
18
-
<meta
19
-
property="og:description"
20
-
content="login to tangled"
21
-
/>
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+
<meta property="og:title" content="login ยท tangled" />
8
+
<meta property="og:url" content="https://tangled.sh/login" />
9
+
<meta property="og:description" content="login to for tangled" />
22
10
<script src="/static/htmx.min.js"></script>
23
-
<link
24
-
rel="stylesheet"
25
-
href="/static/tw.css?{{ cssContentHash }}"
26
-
type="text/css"
27
-
/>
11
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
28
12
<title>login · tangled</title>
29
13
</head>
30
14
<body class="flex items-center justify-center min-h-screen">
31
15
<main class="max-w-md px-6 -mt-4">
32
-
<h1
33
-
class="text-center text-2xl font-semibold italic dark:text-white"
34
-
>
16
+
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >
35
17
tangled
36
18
</h1>
37
19
<h2 class="text-center text-xl italic dark:text-white">
···
51
33
name="handle"
52
34
tabindex="1"
53
35
required
36
+
placeholder="akshay.tngl.sh"
54
37
/>
55
38
<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.
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.
60
42
</span>
61
43
</div>
62
44
63
45
<button
64
-
class="btn w-full my-2 mt-6"
46
+
class="btn w-full my-2 mt-6 text-base "
65
47
type="submit"
66
48
id="login-button"
67
49
tabindex="3"
···
70
52
</button>
71
53
</form>
72
54
<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
-
>.
55
+
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
78
56
</p>
57
+
79
58
<p id="login-msg" class="error w-full"></p>
80
59
</main>
81
60
</body>
+5
-5
appview/pages/templates/user/profile.html
+5
-5
appview/pages/templates/user/profile.html
···
8
8
{{ end }}
9
9
10
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">
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
13
<div class="grid grid-cols-1 gap-4">
14
14
{{ template "user/fragments/profileCard" .Card }}
15
15
{{ block "punchcard" .Punchcard }} {{ end }}
16
16
</div>
17
17
</div>
18
-
<div id="all-repos" class="md:col-span-3 order-2 md:order-2">
18
+
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
19
19
<div class="grid grid-cols-1 gap-4">
20
20
{{ block "ownRepos" . }}{{ end }}
21
21
{{ block "collaboratingRepos" . }}{{ end }}
22
22
</div>
23
23
</div>
24
-
<div class="md:col-span-3 order-3 md:order-3">
24
+
<div class="md:col-span-4 order-3 md:order-3">
25
25
{{ block "profileTimeline" . }}{{ end }}
26
26
</div>
27
27
</div>
···
258
258
</button>
259
259
{{ end }}
260
260
</div>
261
-
<div id="repos" class="grid grid-cols-1 gap-4">
261
+
<div id="repos" class="grid grid-cols-1 gap-4 items-stretch">
262
262
{{ range .Repos }}
263
263
{{ template "user/fragments/repoCard" (list $ . false) }}
264
264
{{ else }}
+3
-3
appview/pages/templates/user/repos.html
+3
-3
appview/pages/templates/user/repos.html
···
8
8
{{ end }}
9
9
10
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">
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
13
{{ template "user/fragments/profileCard" .Card }}
14
14
</div>
15
-
<div id="all-repos" class="md:col-span-6 order-2 md:order-2">
15
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
16
16
{{ block "ownRepos" . }}{{ end }}
17
17
</div>
18
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
11
12
12
"tangled.sh/tangled.sh/core/appview/config"
13
13
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/idresolver"
15
14
"tangled.sh/tangled.sh/core/appview/oauth"
16
15
"tangled.sh/tangled.sh/core/appview/pages"
17
16
"tangled.sh/tangled.sh/core/appview/reporesolver"
18
17
"tangled.sh/tangled.sh/core/eventconsumer"
18
+
"tangled.sh/tangled.sh/core/idresolver"
19
19
"tangled.sh/tangled.sh/core/log"
20
20
"tangled.sh/tangled.sh/core/rbac"
21
21
spindlemodel "tangled.sh/tangled.sh/core/spindle/models"
22
22
23
23
"github.com/go-chi/chi/v5"
24
24
"github.com/gorilla/websocket"
25
-
"github.com/posthog/posthog-go"
26
25
)
27
26
28
27
type Pipelines struct {
···
34
33
spindlestream *eventconsumer.Consumer
35
34
db *db.DB
36
35
enforcer *rbac.Enforcer
37
-
posthog posthog.Client
38
36
logger *slog.Logger
39
37
}
40
38
···
46
44
idResolver *idresolver.Resolver,
47
45
db *db.DB,
48
46
config *config.Config,
49
-
posthog posthog.Client,
50
47
enforcer *rbac.Enforcer,
51
48
) *Pipelines {
52
49
logger := log.New("pipelines")
···
58
55
config: config,
59
56
spindlestream: spindlestream,
60
57
db: db,
61
-
posthog: posthog,
62
58
enforcer: enforcer,
63
59
logger: logger,
64
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
-45
appview/pulls/pulls.go
+44
-45
appview/pulls/pulls.go
···
14
14
"time"
15
15
16
16
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/appview"
18
17
"tangled.sh/tangled.sh/core/appview/config"
19
18
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
19
+
"tangled.sh/tangled.sh/core/appview/notify"
21
20
"tangled.sh/tangled.sh/core/appview/oauth"
22
21
"tangled.sh/tangled.sh/core/appview/pages"
23
22
"tangled.sh/tangled.sh/core/appview/reporesolver"
23
+
"tangled.sh/tangled.sh/core/idresolver"
24
24
"tangled.sh/tangled.sh/core/knotclient"
25
25
"tangled.sh/tangled.sh/core/patchutil"
26
+
"tangled.sh/tangled.sh/core/tid"
26
27
"tangled.sh/tangled.sh/core/types"
27
28
28
29
"github.com/bluekeyes/go-gitdiff/gitdiff"
···
31
32
lexutil "github.com/bluesky-social/indigo/lex/util"
32
33
"github.com/go-chi/chi/v5"
33
34
"github.com/google/uuid"
34
-
"github.com/posthog/posthog-go"
35
35
)
36
36
37
37
type Pulls struct {
···
41
41
idResolver *idresolver.Resolver
42
42
db *db.DB
43
43
config *config.Config
44
-
posthog posthog.Client
44
+
notifier notify.Notifier
45
45
}
46
46
47
47
func New(
···
51
51
resolver *idresolver.Resolver,
52
52
db *db.DB,
53
53
config *config.Config,
54
-
posthog posthog.Client,
54
+
notifier notify.Notifier,
55
55
) *Pulls {
56
56
return &Pulls{
57
57
oauth: oauth,
···
60
60
idResolver: resolver,
61
61
db: db,
62
62
config: config,
63
-
posthog: posthog,
63
+
notifier: notifier,
64
64
}
65
65
}
66
66
···
467
467
468
468
interdiff := patchutil.Interdiff(previousPatch, currentPatch)
469
469
470
-
for _, f := range interdiff.Files {
471
-
log.Println("", "", f.Split())
472
-
}
473
-
474
470
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
475
471
LoggedInUser: s.oauth.GetUser(r),
476
472
RepoInfo: f.RepoInfo(user),
···
559
555
560
556
// we want to group all stacked PRs into just one list
561
557
stacks := make(map[string]db.Stack)
558
+
var shas []string
562
559
n := 0
563
560
for _, p := range pulls {
561
+
// store the sha for later
562
+
shas = append(shas, p.LatestSha())
564
563
// this PR is stacked
565
564
if p.StackId != "" {
566
565
// we have already seen this PR stack
···
579
578
}
580
579
pulls = pulls[:n]
581
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
+
582
598
identsToResolve := make([]string, len(pulls))
583
599
for i, pull := range pulls {
584
600
identsToResolve[i] = pull.OwnerDid
···
600
616
DidHandleMap: didHandleMap,
601
617
FilteringBy: state,
602
618
Stacks: stacks,
619
+
Pipelines: m,
603
620
})
604
-
return
605
621
}
606
622
607
623
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
···
672
688
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
673
689
Collection: tangled.RepoPullCommentNSID,
674
690
Repo: user.Did,
675
-
Rkey: appview.TID(),
691
+
Rkey: tid.TID(),
676
692
Record: &lexutil.LexiconTypeDecoder{
677
693
Val: &tangled.RepoPullComment{
678
694
Repo: &atUri,
···
689
705
return
690
706
}
691
707
692
-
// Create the pull comment in the database with the commentAt field
693
-
commentId, err := db.NewPullComment(tx, &db.PullComment{
708
+
comment := &db.PullComment{
694
709
OwnerDid: user.Did,
695
710
RepoAt: f.RepoAt.String(),
696
711
PullId: pull.PullId,
697
712
Body: body,
698
713
CommentAt: atResp.Uri,
699
714
SubmissionId: pull.Submissions[roundNumber].ID,
700
-
})
715
+
}
716
+
717
+
// Create the pull comment in the database with the commentAt field
718
+
commentId, err := db.NewPullComment(tx, comment)
701
719
if err != nil {
702
720
log.Println("failed to create pull comment", err)
703
721
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
711
729
return
712
730
}
713
731
714
-
if !s.config.Core.Dev {
715
-
err = s.posthog.Enqueue(posthog.Capture{
716
-
DistinctId: user.Did,
717
-
Event: "new_pull_comment",
718
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId},
719
-
})
720
-
if err != nil {
721
-
log.Println("failed to enqueue posthog event:", err)
722
-
}
723
-
}
732
+
s.notifier.NewPullComment(r.Context(), comment)
724
733
725
734
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
726
735
return
···
1049
1058
body = formatPatches[0].Body
1050
1059
}
1051
1060
1052
-
rkey := appview.TID()
1061
+
rkey := tid.TID()
1053
1062
initialSubmission := db.PullSubmission{
1054
1063
Patch: patch,
1055
1064
SourceRev: sourceRev,
1056
1065
}
1057
-
err = db.NewPull(tx, &db.Pull{
1066
+
pull := &db.Pull{
1058
1067
Title: title,
1059
1068
Body: body,
1060
1069
TargetBranch: targetBranch,
···
1065
1074
&initialSubmission,
1066
1075
},
1067
1076
PullSource: pullSource,
1068
-
})
1077
+
}
1078
+
err = db.NewPull(tx, pull)
1069
1079
if err != nil {
1070
1080
log.Println("failed to create pull request", err)
1071
1081
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
1105
1115
return
1106
1116
}
1107
1117
1108
-
if !s.config.Core.Dev {
1109
-
err = s.posthog.Enqueue(posthog.Capture{
1110
-
DistinctId: user.Did,
1111
-
Event: "new_pull",
1112
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId},
1113
-
})
1114
-
if err != nil {
1115
-
log.Println("failed to enqueue posthog event:", err)
1116
-
}
1117
-
}
1118
+
s.notifier.NewPull(r.Context(), pull)
1118
1119
1119
1120
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1120
1121
}
···
1677
1678
}
1678
1679
1679
1680
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1680
-
return
1681
1681
}
1682
1682
1683
1683
func (s *Pulls) resubmitStackedPullHelper(
···
1921
1921
}
1922
1922
1923
1923
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1924
-
return
1925
1924
}
1926
1925
1927
1926
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
···
2045
2044
2046
2045
// auth filter: only owner or collaborators can close
2047
2046
roles := f.RolesInRepo(user)
2047
+
isOwner := roles.IsOwner()
2048
2048
isCollaborator := roles.IsCollaborator()
2049
2049
isPullAuthor := user.Did == pull.OwnerDid
2050
-
isCloseAllowed := isCollaborator || isPullAuthor
2050
+
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2051
2051
if !isCloseAllowed {
2052
2052
log.Println("failed to close pull")
2053
2053
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2091
2091
}
2092
2092
2093
2093
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2094
-
return
2095
2094
}
2096
2095
2097
2096
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···
2113
2112
2114
2113
// auth filter: only owner or collaborators can close
2115
2114
roles := f.RolesInRepo(user)
2115
+
isOwner := roles.IsOwner()
2116
2116
isCollaborator := roles.IsCollaborator()
2117
2117
isPullAuthor := user.Did == pull.OwnerDid
2118
-
isCloseAllowed := isCollaborator || isPullAuthor
2118
+
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2119
2119
if !isCloseAllowed {
2120
2120
log.Println("failed to close pull")
2121
2121
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2159
2159
}
2160
2160
2161
2161
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2162
-
return
2163
2162
}
2164
2163
2165
2164
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
···
2185
2184
2186
2185
title := fp.Title
2187
2186
body := fp.Body
2188
-
rkey := appview.TID()
2187
+
rkey := tid.TID()
2189
2188
2190
2189
initialSubmission := db.PullSubmission{
2191
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
14
"github.com/go-git/go-git/v5/plumbing"
15
15
"github.com/ipfs/go-cid"
16
16
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/appview"
18
17
"tangled.sh/tangled.sh/core/appview/db"
19
18
"tangled.sh/tangled.sh/core/appview/pages"
20
19
"tangled.sh/tangled.sh/core/appview/reporesolver"
21
20
"tangled.sh/tangled.sh/core/knotclient"
21
+
"tangled.sh/tangled.sh/core/tid"
22
22
"tangled.sh/tangled.sh/core/types"
23
23
)
24
24
···
64
64
65
65
log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String())
66
66
67
-
rkey := appview.TID()
67
+
rkey := tid.TID()
68
68
createdAt := time.Now()
69
69
70
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
8
"fmt"
9
9
"io"
10
10
"log"
11
+
"log/slog"
11
12
"net/http"
12
13
"net/url"
13
-
"path"
14
+
"path/filepath"
14
15
"slices"
15
-
"sort"
16
16
"strconv"
17
17
"strings"
18
18
"time"
19
19
20
20
"tangled.sh/tangled.sh/core/api/tangled"
21
-
"tangled.sh/tangled.sh/core/appview"
22
21
"tangled.sh/tangled.sh/core/appview/commitverify"
23
22
"tangled.sh/tangled.sh/core/appview/config"
24
23
"tangled.sh/tangled.sh/core/appview/db"
25
-
"tangled.sh/tangled.sh/core/appview/idresolver"
24
+
"tangled.sh/tangled.sh/core/appview/notify"
26
25
"tangled.sh/tangled.sh/core/appview/oauth"
27
26
"tangled.sh/tangled.sh/core/appview/pages"
28
27
"tangled.sh/tangled.sh/core/appview/pages/markup"
29
28
"tangled.sh/tangled.sh/core/appview/reporesolver"
30
29
"tangled.sh/tangled.sh/core/eventconsumer"
30
+
"tangled.sh/tangled.sh/core/idresolver"
31
31
"tangled.sh/tangled.sh/core/knotclient"
32
32
"tangled.sh/tangled.sh/core/patchutil"
33
33
"tangled.sh/tangled.sh/core/rbac"
34
+
"tangled.sh/tangled.sh/core/tid"
34
35
"tangled.sh/tangled.sh/core/types"
35
36
36
37
securejoin "github.com/cyphar/filepath-securejoin"
37
38
"github.com/go-chi/chi/v5"
38
39
"github.com/go-git/go-git/v5/plumbing"
39
-
"github.com/posthog/posthog-go"
40
40
41
41
comatproto "github.com/bluesky-social/indigo/api/atproto"
42
+
"github.com/bluesky-social/indigo/atproto/syntax"
42
43
lexutil "github.com/bluesky-social/indigo/lex/util"
43
44
)
44
45
···
51
52
spindlestream *eventconsumer.Consumer
52
53
db *db.DB
53
54
enforcer *rbac.Enforcer
54
-
posthog posthog.Client
55
+
notifier notify.Notifier
56
+
logger *slog.Logger
55
57
}
56
58
57
59
func New(
···
62
64
idResolver *idresolver.Resolver,
63
65
db *db.DB,
64
66
config *config.Config,
65
-
posthog posthog.Client,
67
+
notifier notify.Notifier,
66
68
enforcer *rbac.Enforcer,
69
+
logger *slog.Logger,
67
70
) *Repo {
68
71
return &Repo{oauth: oauth,
69
72
repoResolver: repoResolver,
···
72
75
config: config,
73
76
spindlestream: spindlestream,
74
77
db: db,
75
-
posthog: posthog,
78
+
notifier: notifier,
76
79
enforcer: enforcer,
80
+
logger: logger,
77
81
}
78
82
}
79
83
···
179
183
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
180
184
RepoInfo: f.RepoInfo(user),
181
185
})
182
-
return
183
186
}
184
187
185
188
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
···
374
377
375
378
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
376
379
// so we can safely redirect to the "parent" (which is the same file).
377
-
if len(result.Files) == 0 && result.Parent == treePath {
380
+
unescapedTreePath, _ := url.PathUnescape(treePath)
381
+
if len(result.Files) == 0 && result.Parent == unescapedTreePath {
378
382
http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
379
383
return
380
384
}
···
389
393
}
390
394
}
391
395
392
-
baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
393
-
baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
396
+
sortFiles(result.Files)
394
397
395
398
rp.pages.RepoTree(w, pages.RepoTreeParams{
396
399
LoggedInUser: user,
397
400
BreadCrumbs: breadcrumbs,
398
-
BaseTreeLink: baseTreeLink,
399
-
BaseBlobLink: baseBlobLink,
401
+
TreePath: treePath,
400
402
RepoInfo: f.RepoInfo(user),
401
403
RepoTreeResponse: result,
402
404
})
403
-
return
404
405
}
405
406
406
407
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
···
458
459
ArtifactMap: artifactMap,
459
460
DanglingArtifacts: danglingArtifacts,
460
461
})
461
-
return
462
462
}
463
463
464
464
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
···
480
480
return
481
481
}
482
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
-
})
483
+
sortBranches(result.Branches)
499
484
500
485
user := rp.oauth.GetUser(r)
501
486
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
···
503
488
RepoInfo: f.RepoInfo(user),
504
489
RepoBranchesResponse: *result,
505
490
})
506
-
return
507
491
}
508
492
509
493
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
···
554
538
showRendered = r.URL.Query().Get("code") != "true"
555
539
}
556
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
+
557
566
user := rp.oauth.GetUser(r)
558
567
rp.pages.RepoBlob(w, pages.RepoBlobParams{
559
568
LoggedInUser: user,
···
562
571
BreadCrumbs: breadcrumbs,
563
572
ShowRendered: showRendered,
564
573
RenderToggle: renderToggle,
574
+
Unsupported: unsupported,
575
+
IsImage: isImage,
576
+
IsVideo: isVideo,
577
+
ContentSrc: contentSrc,
565
578
})
566
-
return
567
579
}
568
580
569
581
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
570
582
f, err := rp.repoResolver.Resolve(r)
571
583
if err != nil {
572
584
log.Println("failed to get repo and knot", err)
585
+
w.WriteHeader(http.StatusBadRequest)
573
586
return
574
587
}
575
588
···
580
593
if !rp.config.Core.Dev {
581
594
protocol = "https"
582
595
}
583
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
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)
584
598
if err != nil {
585
-
log.Println("failed to reach knotserver", err)
599
+
log.Println("failed to reach knotserver:", err)
600
+
rp.pages.Error503(w)
586
601
return
587
602
}
603
+
defer resp.Body.Close()
588
604
589
-
body, err := io.ReadAll(resp.Body)
590
-
if err != nil {
591
-
log.Printf("Error reading response body: %v", err)
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)
592
609
return
593
610
}
594
611
595
-
var result types.RepoBlobResponse
596
-
err = json.Unmarshal(body, &result)
612
+
contentType := resp.Header.Get("Content-Type")
613
+
body, err := io.ReadAll(resp.Body)
597
614
if err != nil {
598
-
log.Println("failed to parse response:", err)
615
+
log.Printf("error reading response body from knotserver: %v", err)
616
+
w.WriteHeader(http.StatusInternalServerError)
599
617
return
600
618
}
601
619
602
-
if result.IsBinary {
603
-
w.Header().Set("Content-Type", "application/octet-stream")
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)
604
625
w.Write(body)
626
+
} else {
627
+
w.WriteHeader(http.StatusUnsupportedMediaType)
628
+
w.Write([]byte("unsupported content type"))
605
629
return
606
630
}
607
-
608
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
609
-
w.Write([]byte(result.Contents))
610
-
return
611
631
}
612
632
613
633
// modify the spindle configured for this repo
614
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
+
615
646
f, err := rp.repoResolver.Resolve(r)
616
647
if err != nil {
617
-
log.Println("failed to get repo and knot", err)
618
-
w.WriteHeader(http.StatusBadRequest)
648
+
fail("Failed to resolve repo. Try again later", err)
619
649
return
620
650
}
621
651
622
652
repoAt := f.RepoAt
623
653
rkey := repoAt.RecordKey().String()
624
654
if rkey == "" {
625
-
log.Println("invalid aturi for repo", err)
626
-
w.WriteHeader(http.StatusInternalServerError)
655
+
fail("Failed to resolve repo. Try again later", err)
627
656
return
628
657
}
629
-
630
-
user := rp.oauth.GetUser(r)
631
658
632
659
newSpindle := r.FormValue("spindle")
633
660
client, err := rp.oauth.AuthorizedClient(r)
634
661
if err != nil {
635
-
log.Println("failed to get client")
636
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
662
+
fail("Failed to authorize. Try again later.", err)
637
663
return
638
664
}
639
665
640
666
// ensure that this is a valid spindle for this user
641
667
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
642
668
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.")
669
+
fail("Failed to find spindles. Try again later.", err)
645
670
return
646
671
}
647
672
648
673
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.")
674
+
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
651
675
return
652
676
}
653
677
654
678
// optimistic update
655
679
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
656
680
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.")
681
+
fail("Failed to update spindle. Try again later.", err)
659
682
return
660
683
}
661
684
662
685
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
663
686
if err != nil {
664
-
// failed to get record
665
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.")
687
+
fail("Failed to update spindle, no record found on PDS.", err)
666
688
return
667
689
}
668
690
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
···
683
705
})
684
706
685
707
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.")
708
+
fail("Failed to update spindle, unable to save to PDS.", err)
689
709
return
690
710
}
691
711
···
695
715
eventconsumer.NewSpindleSource(newSpindle),
696
716
)
697
717
698
-
w.Write(fmt.Append(nil, "spindle set to: ", newSpindle))
718
+
rp.pages.HxRefresh(w)
699
719
}
700
720
701
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
+
702
727
f, err := rp.repoResolver.Resolve(r)
703
728
if err != nil {
704
-
log.Println("failed to get repo and knot", err)
729
+
l.Error("failed to get repo and knot", "err", err)
705
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)
706
737
}
707
738
708
739
collaborator := r.FormValue("collaborator")
709
740
if collaborator == "" {
710
-
http.Error(w, "malformed form", http.StatusBadRequest)
741
+
fail("Invalid form.", nil)
711
742
return
712
743
}
713
744
714
745
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
715
746
if err != nil {
716
-
w.Write([]byte("failed to resolve collaborator did to a handle"))
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)
717
753
return
718
754
}
719
-
log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
755
+
l = l.With("collaborator", collaboratorIdent.Handle)
756
+
l = l.With("knot", f.Knot)
720
757
721
-
// TODO: create an atproto record for this
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
+
}
722
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")
723
789
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
724
790
if err != nil {
725
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
791
+
fail("Failed to add to knot.", err)
726
792
return
727
793
}
728
794
729
795
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
730
796
if err != nil {
731
-
log.Println("failed to create client to ", f.Knot)
797
+
fail("Failed to add to knot.", err)
732
798
return
733
799
}
734
800
735
801
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
736
802
if err != nil {
737
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
803
+
fail("Knot was unreachable.", err)
738
804
return
739
805
}
740
806
741
807
if ksResp.StatusCode != http.StatusNoContent {
742
-
w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err))
808
+
fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil)
743
809
return
744
810
}
745
811
746
812
tx, err := rp.db.BeginTx(r.Context(), nil)
747
813
if err != nil {
748
-
log.Println("failed to start tx")
749
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
814
+
fail("Failed to add collaborator.", err)
750
815
return
751
816
}
752
817
defer func() {
753
818
tx.Rollback()
754
819
err = rp.enforcer.E.LoadPolicy()
755
820
if err != nil {
756
-
log.Println("failed to rollback policies")
821
+
fail("Failed to add collaborator.", err)
757
822
}
758
823
}()
759
824
760
825
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
761
826
if err != nil {
762
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
827
+
fail("Failed to add collaborator permissions.", err)
763
828
return
764
829
}
765
830
766
-
err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
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
+
})
767
838
if err != nil {
768
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
839
+
fail("Failed to add collaborator.", err)
769
840
return
770
841
}
771
842
772
843
err = tx.Commit()
773
844
if err != nil {
774
-
log.Println("failed to commit changes", err)
775
-
http.Error(w, err.Error(), http.StatusInternalServerError)
845
+
fail("Failed to add collaborator.", err)
776
846
return
777
847
}
778
848
779
849
err = rp.enforcer.E.SavePolicy()
780
850
if err != nil {
781
-
log.Println("failed to update ACLs", err)
782
-
http.Error(w, err.Error(), http.StatusInternalServerError)
851
+
fail("Failed to update collaborator permissions.", err)
783
852
return
784
853
}
785
854
786
-
w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String()))
787
-
855
+
rp.pages.HxRefresh(w)
788
856
}
789
857
790
858
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
···
936
1004
w.Write(fmt.Append(nil, "default branch set to: ", branch))
937
1005
}
938
1006
939
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
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
+
940
1013
f, err := rp.repoResolver.Resolve(r)
941
1014
if err != nil {
942
1015
log.Println("failed to get repo and knot", err)
943
1016
return
944
1017
}
945
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
+
946
1046
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
-
}
1047
+
case http.MethodPut:
1048
+
errorId := "add-secret-error"
954
1049
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
-
}
1050
+
value := r.FormValue("value")
1051
+
if value == "" {
1052
+
w.WriteHeader(http.StatusBadRequest)
1053
+
return
961
1054
}
962
1055
963
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
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
+
)
964
1065
if err != nil {
965
-
log.Println("failed to create unsigned client", err)
1066
+
l.Error("Failed to add secret.", "err", err)
1067
+
rp.pages.Notice(w, errorId, "Failed to add secret.")
966
1068
return
967
1069
}
968
1070
969
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
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
+
)
970
1082
if err != nil {
971
-
log.Println("failed to reach knotserver", err)
1083
+
l.Error("Failed to delete secret.", "err", err)
1084
+
rp.pages.Notice(w, errorId, "Failed to delete secret.")
972
1085
return
973
1086
}
1087
+
}
974
1088
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
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
980
1248
}
1249
+
}
981
1250
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,
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(),
990
1270
})
991
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
+
})
992
1282
}
993
1283
994
1284
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
···
1108
1398
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1109
1399
sourceAt := f.RepoAt.String()
1110
1400
1111
-
rkey := appview.TID()
1401
+
rkey := tid.TID()
1112
1402
repo := &db.Repo{
1113
1403
Did: user.Did,
1114
1404
Name: forkName,
···
1233
1523
return
1234
1524
}
1235
1525
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
-
})
1526
+
1527
+
sortBranches(branches)
1239
1528
1240
1529
var defaultBranch string
1241
1530
for _, b := range branches {
+34
appview/repo/repo_util.go
+34
appview/repo/repo_util.go
···
5
5
"crypto/rand"
6
6
"fmt"
7
7
"math/big"
8
+
"slices"
9
+
"sort"
10
+
"strings"
8
11
9
12
"tangled.sh/tangled.sh/core/appview/db"
10
13
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
14
+
"tangled.sh/tangled.sh/core/types"
11
15
12
16
"github.com/go-git/go-git/v5/plumbing/object"
13
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
+
}
14
48
15
49
func uniqueEmails(commits []*object.Commit) []string {
16
50
emails := make(map[string]struct{})
+2
appview/repo/router.go
+2
appview/repo/router.go
···
74
74
r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator)
75
75
r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo)
76
76
r.Put("/branches/default", rp.SetDefaultBranch)
77
+
r.Put("/secrets", rp.Secrets)
78
+
r.Delete("/secrets", rp.Secrets)
77
79
})
78
80
})
79
81
+5
-4
appview/reporesolver/resolver.go
+5
-4
appview/reporesolver/resolver.go
···
17
17
"github.com/go-chi/chi/v5"
18
18
"tangled.sh/tangled.sh/core/appview/config"
19
19
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
21
20
"tangled.sh/tangled.sh/core/appview/oauth"
22
21
"tangled.sh/tangled.sh/core/appview/pages"
23
22
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
23
+
"tangled.sh/tangled.sh/core/idresolver"
24
24
"tangled.sh/tangled.sh/core/knotclient"
25
25
"tangled.sh/tangled.sh/core/rbac"
26
26
)
···
149
149
for _, item := range repoCollaborators {
150
150
// currently only two roles: owner and member
151
151
var role string
152
-
if item[3] == "repo:owner" {
152
+
switch item[3] {
153
+
case "repo:owner":
153
154
role = "owner"
154
-
} else if item[3] == "repo:collaborator" {
155
+
case "repo:collaborator":
155
156
role = "collaborator"
156
-
} else {
157
+
default:
157
158
continue
158
159
}
159
160
+2
-2
appview/settings/settings.go
+2
-2
appview/settings/settings.go
···
12
12
13
13
"github.com/go-chi/chi/v5"
14
14
"tangled.sh/tangled.sh/core/api/tangled"
15
-
"tangled.sh/tangled.sh/core/appview"
16
15
"tangled.sh/tangled.sh/core/appview/config"
17
16
"tangled.sh/tangled.sh/core/appview/db"
18
17
"tangled.sh/tangled.sh/core/appview/email"
19
18
"tangled.sh/tangled.sh/core/appview/middleware"
20
19
"tangled.sh/tangled.sh/core/appview/oauth"
21
20
"tangled.sh/tangled.sh/core/appview/pages"
21
+
"tangled.sh/tangled.sh/core/tid"
22
22
23
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
24
lexutil "github.com/bluesky-social/indigo/lex/util"
···
366
366
return
367
367
}
368
368
369
-
rkey := appview.TID()
369
+
rkey := tid.TID()
370
370
371
371
tx, err := s.Db.Begin()
372
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
10
11
11
"github.com/go-chi/chi/v5"
12
12
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview"
14
13
"tangled.sh/tangled.sh/core/appview/config"
15
14
"tangled.sh/tangled.sh/core/appview/db"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
17
15
"tangled.sh/tangled.sh/core/appview/middleware"
18
16
"tangled.sh/tangled.sh/core/appview/oauth"
19
17
"tangled.sh/tangled.sh/core/appview/pages"
20
18
verify "tangled.sh/tangled.sh/core/appview/spindleverify"
19
+
"tangled.sh/tangled.sh/core/idresolver"
21
20
"tangled.sh/tangled.sh/core/rbac"
21
+
"tangled.sh/tangled.sh/core/tid"
22
22
23
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
24
"github.com/bluesky-social/indigo/atproto/syntax"
···
114
114
}
115
115
116
116
identsToResolve := make([]string, len(members))
117
-
for i, member := range members {
118
-
identsToResolve[i] = member
119
-
}
117
+
copy(identsToResolve, members)
120
118
resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve)
121
119
didHandleMap := make(map[string]string)
122
120
for _, identity := range resolvedIds {
···
258
256
259
257
// ok
260
258
s.Pages.HxRefresh(w)
261
-
return
262
259
}
263
260
264
261
func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) {
···
306
303
s.Enforcer.E.LoadPolicy()
307
304
}()
308
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
+
309
318
err = db.DeleteSpindle(
310
319
tx,
311
320
db.FilterEq("owner", user.Did),
···
524
533
s.Enforcer.E.LoadPolicy()
525
534
}()
526
535
527
-
rkey := appview.TID()
536
+
rkey := tid.TID()
528
537
529
538
// add member to db
530
539
if err = db.AddSpindleMember(tx, db.SpindleMember{
···
711
720
712
721
// ok
713
722
s.Pages.HxRefresh(w)
714
-
return
715
723
}
+13
-26
appview/state/follow.go
+13
-26
appview/state/follow.go
···
7
7
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
-
"github.com/posthog/posthog-go"
11
10
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview"
13
11
"tangled.sh/tangled.sh/core/appview/db"
14
12
"tangled.sh/tangled.sh/core/appview/pages"
13
+
"tangled.sh/tangled.sh/core/tid"
15
14
)
16
15
17
16
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
···
42
41
switch r.Method {
43
42
case http.MethodPost:
44
43
createdAt := time.Now().Format(time.RFC3339)
45
-
rkey := appview.TID()
44
+
rkey := tid.TID()
46
45
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
47
46
Collection: tangled.GraphFollowNSID,
48
47
Repo: currentUser.Did,
···
58
57
return
59
58
}
60
59
61
-
err = db.AddFollow(s.db, currentUser.Did, subjectIdent.DID.String(), rkey)
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)
62
69
if err != nil {
63
70
log.Println("failed to follow", err)
64
71
return
65
72
}
66
73
67
-
log.Println("created atproto record: ", resp.Uri)
74
+
s.notifier.NewFollow(r.Context(), follow)
68
75
69
76
s.pages.FollowFragment(w, pages.FollowFragmentParams{
70
77
UserDid: subjectIdent.DID.String(),
71
78
FollowStatus: db.IsFollowing,
72
79
})
73
80
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
81
return
86
82
case http.MethodDelete:
87
83
// find the record in the db
···
113
109
FollowStatus: db.IsNotFollowing,
114
110
})
115
111
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
-
}
112
+
s.notifier.DeleteFollow(r.Context(), follow)
126
113
127
114
return
128
115
}
+1
-29
appview/state/profile.go
+1
-29
appview/state/profile.go
···
1
1
package state
2
2
3
3
import (
4
-
"crypto/hmac"
5
-
"crypto/sha256"
6
-
"encoding/hex"
7
4
"fmt"
8
5
"log"
9
6
"net/http"
···
16
13
"github.com/bluesky-social/indigo/atproto/syntax"
17
14
lexutil "github.com/bluesky-social/indigo/lex/util"
18
15
"github.com/go-chi/chi/v5"
19
-
"github.com/posthog/posthog-go"
20
16
"tangled.sh/tangled.sh/core/api/tangled"
21
17
"tangled.sh/tangled.sh/core/appview/db"
22
18
"tangled.sh/tangled.sh/core/appview/pages"
···
143
139
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
144
140
}
145
141
146
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
147
142
s.pages.ProfilePage(w, pages.ProfilePageParams{
148
143
LoggedInUser: loggedInUser,
149
144
Repos: pinnedRepos,
···
152
147
Card: pages.ProfileCard{
153
148
UserDid: ident.DID.String(),
154
149
UserHandle: ident.Handle.String(),
155
-
AvatarUri: profileAvatarUri,
156
150
Profile: profile,
157
151
FollowStatus: followStatus,
158
152
Followers: followers,
···
195
189
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
196
190
}
197
191
198
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
199
-
200
192
s.pages.ReposPage(w, pages.ReposPageParams{
201
193
LoggedInUser: loggedInUser,
202
194
Repos: repos,
···
204
196
Card: pages.ProfileCard{
205
197
UserDid: ident.DID.String(),
206
198
UserHandle: ident.Handle.String(),
207
-
AvatarUri: profileAvatarUri,
208
199
Profile: profile,
209
200
FollowStatus: followStatus,
210
201
Followers: followers,
···
213
204
})
214
205
}
215
206
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
207
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
225
208
user := s.oauth.GetUser(r)
226
209
···
266
249
}
267
250
268
251
s.updateProfile(profile, w, r)
269
-
return
270
252
}
271
253
272
254
func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
···
306
288
profile.PinnedRepos = pinnedRepos
307
289
308
290
s.updateProfile(profile, w, r)
309
-
return
310
291
}
311
292
312
293
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
···
371
352
return
372
353
}
373
354
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
-
}
355
+
s.notifier.UpdateProfile(r.Context(), profile)
383
356
384
357
s.pages.HxRedirect(w, "/"+user.Did)
385
-
return
386
358
}
387
359
388
360
func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
+8
-8
appview/state/reaction.go
+8
-8
appview/state/reaction.go
···
10
10
11
11
lexutil "github.com/bluesky-social/indigo/lex/util"
12
12
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview"
14
13
"tangled.sh/tangled.sh/core/appview/db"
15
14
"tangled.sh/tangled.sh/core/appview/pages"
15
+
"tangled.sh/tangled.sh/core/tid"
16
16
)
17
17
18
18
func (s *State) React(w http.ResponseWriter, r *http.Request) {
···
45
45
switch r.Method {
46
46
case http.MethodPost:
47
47
createdAt := time.Now().Format(time.RFC3339)
48
-
rkey := appview.TID()
48
+
rkey := tid.TID()
49
49
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
50
50
Collection: tangled.FeedReactionNSID,
51
51
Repo: currentUser.Did,
···
77
77
log.Println("created atproto record: ", resp.Uri)
78
78
79
79
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
80
-
ThreadAt: subjectUri,
81
-
Kind: reactionKind,
82
-
Count: count,
80
+
ThreadAt: subjectUri,
81
+
Kind: reactionKind,
82
+
Count: count,
83
83
IsReacted: true,
84
84
})
85
85
···
115
115
}
116
116
117
117
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
118
-
ThreadAt: subjectUri,
119
-
Kind: reactionKind,
120
-
Count: count,
118
+
ThreadAt: subjectUri,
119
+
Kind: reactionKind,
120
+
Count: count,
121
121
IsReacted: false,
122
122
})
123
123
+35
-7
appview/state/router.go
+35
-7
appview/state/router.go
···
14
14
"tangled.sh/tangled.sh/core/appview/pulls"
15
15
"tangled.sh/tangled.sh/core/appview/repo"
16
16
"tangled.sh/tangled.sh/core/appview/settings"
17
+
"tangled.sh/tangled.sh/core/appview/signup"
17
18
"tangled.sh/tangled.sh/core/appview/spindles"
18
19
"tangled.sh/tangled.sh/core/appview/state/userutil"
20
+
avstrings "tangled.sh/tangled.sh/core/appview/strings"
19
21
"tangled.sh/tangled.sh/core/log"
20
22
)
21
23
···
65
67
66
68
func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
67
69
r := chi.NewRouter()
68
-
69
-
// strip @ from user
70
-
r.Use(middleware.StripLeadingAt)
71
70
72
71
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
73
72
r.Get("/", s.Profile)
···
135
134
})
136
135
137
136
r.Mount("/settings", s.SettingsRouter())
137
+
r.Mount("/strings", s.StringsRouter(mw))
138
138
r.Mount("/knots", s.KnotsRouter(mw))
139
139
r.Mount("/spindles", s.SpindlesRouter())
140
+
r.Mount("/signup", s.SignupRouter())
140
141
r.Mount("/", s.OAuthRouter())
141
142
142
143
r.Get("/keys/{user}", s.Keys)
144
+
r.Get("/terms", s.TermsOfService)
145
+
r.Get("/privacy", s.PrivacyPolicy)
143
146
144
147
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
145
148
s.pages.Error404(w)
···
197
200
return knots.Router(mw)
198
201
}
199
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
+
200
220
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)
221
+
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
202
222
return issues.Router(mw)
203
223
}
204
224
205
225
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)
226
+
pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
207
227
return pulls.Router(mw)
208
228
}
209
229
210
230
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)
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)
212
233
return repo.Router(mw)
213
234
}
214
235
215
236
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)
237
+
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
217
238
return pipes.Router(mw)
218
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
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
-
"github.com/posthog/posthog-go"
12
11
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview"
14
12
"tangled.sh/tangled.sh/core/appview/db"
15
13
"tangled.sh/tangled.sh/core/appview/pages"
14
+
"tangled.sh/tangled.sh/core/tid"
16
15
)
17
16
18
17
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
···
39
38
switch r.Method {
40
39
case http.MethodPost:
41
40
createdAt := time.Now().Format(time.RFC3339)
42
-
rkey := appview.TID()
41
+
rkey := tid.TID()
43
42
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
44
43
Collection: tangled.FeedStarNSID,
45
44
Repo: currentUser.Did,
···
54
53
log.Println("failed to create atproto record", err)
55
54
return
56
55
}
56
+
log.Println("created atproto record: ", resp.Uri)
57
57
58
-
err = db.AddStar(s.db, currentUser.Did, subjectUri, rkey)
58
+
star := &db.Star{
59
+
StarredByDid: currentUser.Did,
60
+
RepoAt: subjectUri,
61
+
Rkey: rkey,
62
+
}
63
+
64
+
err = db.AddStar(s.db, star)
59
65
if err != nil {
60
66
log.Println("failed to star", err)
61
67
return
···
66
72
log.Println("failed to get star count for ", subjectUri)
67
73
}
68
74
69
-
log.Println("created atproto record: ", resp.Uri)
75
+
s.notifier.NewStar(r.Context(), star)
70
76
71
-
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
77
+
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
72
78
IsStarred: true,
73
79
RepoAt: subjectUri,
74
80
Stats: db.RepoStats{
···
76
82
},
77
83
})
78
84
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
85
return
91
86
case http.MethodDelete:
92
87
// find the record in the db
···
119
114
return
120
115
}
121
116
122
-
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
117
+
s.notifier.DeleteStar(r.Context(), star)
118
+
119
+
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
123
120
IsStarred: false,
124
121
RepoAt: subjectUri,
125
122
Stats: db.RepoStats{
126
123
StarCount: starCount,
127
124
},
128
125
})
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
126
141
127
return
142
128
}
+28
-356
appview/state/state.go
+28
-356
appview/state/state.go
···
10
10
"time"
11
11
12
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
-
"github.com/bluesky-social/indigo/atproto/syntax"
14
13
lexutil "github.com/bluesky-social/indigo/lex/util"
15
14
securejoin "github.com/cyphar/filepath-securejoin"
16
15
"github.com/go-chi/chi/v5"
···
21
20
"tangled.sh/tangled.sh/core/appview/cache/session"
22
21
"tangled.sh/tangled.sh/core/appview/config"
23
22
"tangled.sh/tangled.sh/core/appview/db"
24
-
"tangled.sh/tangled.sh/core/appview/idresolver"
23
+
"tangled.sh/tangled.sh/core/appview/notify"
25
24
"tangled.sh/tangled.sh/core/appview/oauth"
26
25
"tangled.sh/tangled.sh/core/appview/pages"
26
+
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
27
27
"tangled.sh/tangled.sh/core/appview/reporesolver"
28
28
"tangled.sh/tangled.sh/core/eventconsumer"
29
+
"tangled.sh/tangled.sh/core/idresolver"
29
30
"tangled.sh/tangled.sh/core/jetstream"
30
31
"tangled.sh/tangled.sh/core/knotclient"
31
32
tlog "tangled.sh/tangled.sh/core/log"
32
33
"tangled.sh/tangled.sh/core/rbac"
34
+
"tangled.sh/tangled.sh/core/tid"
33
35
)
34
36
35
37
type State struct {
36
38
db *db.DB
39
+
notifier notify.Notifier
37
40
oauth *oauth.OAuth
38
41
enforcer *rbac.Enforcer
39
-
tidClock syntax.TIDClock
40
42
pages *pages.Pages
41
43
sess *session.SessionStore
42
44
idResolver *idresolver.Resolver
···
59
61
return nil, fmt.Errorf("failed to create enforcer: %w", err)
60
62
}
61
63
62
-
clock := syntax.NewTIDClock(0)
63
-
64
64
pgs := pages.NewPages(config)
65
65
66
-
res, err := idresolver.RedisResolver(config.Redis)
66
+
res, err := idresolver.RedisResolver(config.Redis.ToURL())
67
67
if err != nil {
68
68
log.Printf("failed to create redis resolver: %v", err)
69
69
res = idresolver.DefaultResolver()
···
93
93
tangled.ActorProfileNSID,
94
94
tangled.SpindleMemberNSID,
95
95
tangled.SpindleNSID,
96
+
tangled.StringNSID,
96
97
},
97
98
nil,
98
99
slog.Default(),
···
131
132
}
132
133
spindlestream.Start(ctx)
133
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
+
134
141
state := &State{
135
142
d,
143
+
notifier,
136
144
oauth,
137
145
enforcer,
138
-
clock,
139
146
pgs,
140
147
sess,
141
148
res,
···
150
157
return state, nil
151
158
}
152
159
153
-
func TID(c *syntax.TIDClock) string {
154
-
return c.Next().String()
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
+
})
155
172
}
156
173
157
174
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
···
198
215
return
199
216
}
200
217
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
218
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
238
219
user := chi.URLParam(r, "user")
239
220
user = strings.TrimPrefix(user, "@")
···
266
247
}
267
248
}
268
249
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
250
func validateRepoName(name string) error {
570
251
// check for path traversal attempts
571
252
if name == "." || name == ".." ||
···
664
345
return
665
346
}
666
347
667
-
rkey := appview.TID()
348
+
rkey := tid.TID()
668
349
repo := &db.Repo{
669
350
Did: user.Did,
670
351
Name: repoName,
···
760
441
return
761
442
}
762
443
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
-
}
444
+
s.notifier.NewRepo(r.Context(), repo)
773
445
774
446
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
775
447
return
+6
appview/state/userutil/userutil.go
+6
appview/state/userutil/userutil.go
···
51
51
func IsDid(s string) bool {
52
52
return didRegex.MatchString(s)
53
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
87
88
88
return &out, nil
89
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
1
export default {
2
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
+
3
17
const url = new URL(request.url);
4
18
const { pathname, searchParams } = url;
5
19
···
60
74
const profile = await profileResponse.json();
61
75
const avatar = profile.avatar;
62
76
63
-
if (!avatar) {
64
-
return new Response(`avatar not found for ${actor}.`, { status: 404 });
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;
65
94
}
66
95
67
96
// Resize if requested
68
97
let avatarResponse;
69
98
if (resizeToTiny) {
70
-
avatarResponse = await fetch(avatar, {
99
+
avatarResponse = await fetch(avatarUrl, {
71
100
cf: {
72
101
image: {
73
102
width: 32,
···
78
107
},
79
108
});
80
109
} else {
81
-
avatarResponse = await fetch(avatar);
110
+
avatarResponse = await fetch(avatarUrl);
82
111
}
83
112
84
113
if (!avatarResponse.ok) {
+2
cmd/gen.go
+2
cmd/gen.go
···
40
40
tangled.PublicKey{},
41
41
tangled.Repo{},
42
42
tangled.RepoArtifact{},
43
+
tangled.RepoCollaborator{},
43
44
tangled.RepoIssue{},
44
45
tangled.RepoIssueComment{},
45
46
tangled.RepoIssueState{},
···
49
50
tangled.RepoPullStatus{},
50
51
tangled.Spindle{},
51
52
tangled.SpindleMember{},
53
+
tangled.String{},
52
54
); err != nil {
53
55
panic(err)
54
56
}
+46
-3
docs/hacking.md
+46
-3
docs/hacking.md
···
32
32
nix run .#watch-tailwind
33
33
```
34
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
+
35
51
## running a knot
36
52
37
53
An end-to-end knot setup requires setting up a machine with
···
39
55
quite cumbersome. So the nix flake provides a
40
56
`nixosConfiguration` to do so.
41
57
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.
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.
45
62
46
63
You can now start a lightweight NixOS VM using
47
64
`nixos-shell` like so:
···
71
88
git remote add local-dev git@nixos-shell:user/repo
72
89
git push local-dev main
73
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
191
```
192
192
193
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
13
14
14
### the engine
15
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.
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.
19
20
20
21
The base image for the container is constructed on the fly using
21
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
57
depth: 50
58
58
submodules: true
59
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
1
{
2
2
"nodes": {
3
+
"flake-utils": {
4
+
"inputs": {
5
+
"systems": "systems"
6
+
},
7
+
"locked": {
8
+
"lastModified": 1694529238,
9
+
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
10
+
"owner": "numtide",
11
+
"repo": "flake-utils",
12
+
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
13
+
"type": "github"
14
+
},
15
+
"original": {
16
+
"owner": "numtide",
17
+
"repo": "flake-utils",
18
+
"type": "github"
19
+
}
20
+
},
3
21
"gitignore": {
4
22
"inputs": {
5
23
"nixpkgs": [
···
17
35
"original": {
18
36
"owner": "hercules-ci",
19
37
"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
38
"type": "github"
39
39
}
40
40
},
···
128
128
"lucide-src": {
129
129
"flake": false,
130
130
"locked": {
131
-
"lastModified": 1742302029,
132
-
"narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=",
131
+
"lastModified": 1754044466,
132
+
"narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=",
133
133
"type": "tarball",
134
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
134
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
135
135
},
136
136
"original": {
137
137
"type": "tarball",
138
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
138
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
139
139
}
140
140
},
141
141
"nixpkgs": {
+28
-4
flake.nix
+28
-4
flake.nix
···
22
22
flake = false;
23
23
};
24
24
lucide-src = {
25
-
url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip";
25
+
url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip";
26
26
flake = false;
27
27
};
28
28
inter-fonts-src = {
···
190
190
};
191
191
});
192
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;};
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
+
};
196
220
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
197
221
};
198
222
}
+54
-34
go.mod
+54
-34
go.mod
···
1
1
module tangled.sh/tangled.sh/core
2
2
3
-
go 1.24.0
4
-
5
-
toolchain go1.24.3
3
+
go 1.24.4
6
4
7
5
require (
8
6
github.com/Blank-Xu/sql-adapter v1.1.1
7
+
github.com/alecthomas/assert/v2 v2.11.0
9
8
github.com/alecthomas/chroma/v2 v2.15.0
9
+
github.com/avast/retry-go/v4 v4.6.1
10
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
-
github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e
11
+
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb
12
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
13
github.com/carlmjohnson/versioninfo v0.22.5
14
14
github.com/casbin/casbin/v2 v2.103.0
15
+
github.com/cloudflare/cloudflare-go v0.115.0
15
16
github.com/cyphar/filepath-securejoin v0.4.1
16
17
github.com/dgraph-io/ristretto v0.2.0
17
18
github.com/docker/docker v28.2.2+incompatible
···
22
23
github.com/go-git/go-git/v5 v5.14.0
23
24
github.com/google/uuid v1.6.0
24
25
github.com/gorilla/sessions v1.4.0
25
-
github.com/gorilla/websocket v1.5.3
26
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
26
27
github.com/hiddeco/sshsig v0.2.0
27
28
github.com/hpcloud/tail v1.0.0
28
29
github.com/ipfs/go-cid v0.5.0
29
30
github.com/lestrrat-go/jwx/v2 v2.1.6
30
31
github.com/mattn/go-sqlite3 v1.14.24
31
32
github.com/microcosm-cc/bluemonday v1.0.27
33
+
github.com/openbao/openbao/api/v2 v2.3.0
32
34
github.com/posthog/posthog-go v1.5.5
33
-
github.com/redis/go-redis/v9 v9.3.0
35
+
github.com/redis/go-redis/v9 v9.7.3
34
36
github.com/resend/resend-go/v2 v2.15.0
35
37
github.com/sethvargo/go-envconfig v1.1.0
36
38
github.com/stretchr/testify v1.10.0
37
39
github.com/urfave/cli/v3 v3.3.3
38
40
github.com/whyrusleeping/cbor-gen v0.3.1
39
41
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/crypto v0.40.0
43
+
golang.org/x/net v0.42.0
44
+
golang.org/x/sync v0.16.0
42
45
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
43
46
gopkg.in/yaml.v3 v3.0.1
44
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421
47
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1
45
48
)
46
49
47
50
require (
48
51
dario.cat/mergo v1.0.1 // indirect
49
52
github.com/Microsoft/go-winio v0.6.2 // indirect
50
-
github.com/ProtonMail/go-crypto v1.2.0 // indirect
53
+
github.com/ProtonMail/go-crypto v1.3.0 // indirect
54
+
github.com/alecthomas/repr v0.4.0 // indirect
51
55
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
52
-
github.com/avast/retry-go/v4 v4.6.1 // indirect
53
56
github.com/aymerick/douceur v0.2.0 // indirect
54
57
github.com/beorn7/perks v1.0.1 // indirect
55
58
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
56
59
github.com/casbin/govaluate v1.3.0 // indirect
60
+
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
57
61
github.com/cespare/xxhash/v2 v2.3.0 // indirect
58
-
github.com/cloudflare/circl v1.6.0 // indirect
62
+
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect
59
63
github.com/containerd/errdefs v1.0.0 // indirect
60
64
github.com/containerd/errdefs/pkg v0.3.0 // indirect
61
65
github.com/containerd/log v0.1.0 // indirect
···
68
72
github.com/docker/go-units v0.5.0 // indirect
69
73
github.com/emirpasic/gods v1.18.1 // indirect
70
74
github.com/felixge/httpsnoop v1.0.4 // indirect
75
+
github.com/fsnotify/fsnotify v1.6.0 // indirect
71
76
github.com/go-enry/go-oniguruma v1.2.1 // indirect
72
77
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
73
78
github.com/go-git/go-billy/v5 v5.6.2 // indirect
74
-
github.com/go-logr/logr v1.4.2 // indirect
79
+
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
80
+
github.com/go-logr/logr v1.4.3 // indirect
75
81
github.com/go-logr/stdr v1.2.2 // indirect
76
82
github.com/go-redis/cache/v9 v9.0.0 // indirect
83
+
github.com/go-test/deep v1.1.1 // indirect
77
84
github.com/goccy/go-json v0.10.5 // indirect
78
85
github.com/gogo/protobuf v1.3.2 // indirect
79
-
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
86
+
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
80
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
81
90
github.com/gorilla/css v1.0.1 // indirect
82
91
github.com/gorilla/securecookie v1.1.2 // indirect
92
+
github.com/hashicorp/errwrap v1.1.0 // indirect
83
93
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
84
-
github.com/hashicorp/go-retryablehttp v0.7.7 // 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
85
99
github.com/hashicorp/golang-lru v1.0.2 // indirect
86
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
87
103
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
104
+
github.com/ipfs/boxo v0.33.0 // indirect
105
+
github.com/ipfs/go-block-format v0.2.2 // indirect
90
106
github.com/ipfs/go-datastore v0.8.2 // indirect
91
107
github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
92
108
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
109
+
github.com/ipfs/go-ipld-cbor v0.2.1 // indirect
110
+
github.com/ipfs/go-ipld-format v0.6.2 // indirect
95
111
github.com/ipfs/go-log v1.0.5 // indirect
96
112
github.com/ipfs/go-log/v2 v2.6.0 // indirect
97
113
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
98
114
github.com/kevinburke/ssh_config v1.2.0 // indirect
99
115
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
116
+
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
117
+
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
102
118
github.com/lestrrat-go/httpcc v1.0.1 // indirect
103
119
github.com/lestrrat-go/httprc v1.0.6 // indirect
104
120
github.com/lestrrat-go/iter v1.0.2 // indirect
105
121
github.com/lestrrat-go/option v1.0.1 // indirect
106
122
github.com/mattn/go-isatty v0.0.20 // indirect
107
123
github.com/minio/sha256-simd v1.0.1 // indirect
124
+
github.com/mitchellh/mapstructure v1.5.0 // indirect
108
125
github.com/moby/docker-image-spec v1.3.1 // indirect
109
126
github.com/moby/sys/atomicwriter v0.1.0 // indirect
110
127
github.com/moby/term v0.5.2 // indirect
···
116
133
github.com/multiformats/go-multihash v0.2.3 // indirect
117
134
github.com/multiformats/go-varint v0.0.7 // indirect
118
135
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
136
+
github.com/onsi/gomega v1.37.0 // indirect
119
137
github.com/opencontainers/go-digest v1.0.0 // indirect
120
138
github.com/opencontainers/image-spec v1.1.1 // indirect
121
-
github.com/opentracing/opentracing-go v1.2.0 // indirect
139
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
122
140
github.com/pjbgf/sha1cd v0.3.2 // indirect
123
141
github.com/pkg/errors v0.9.1 // indirect
124
142
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
125
143
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
126
144
github.com/prometheus/client_golang v1.22.0 // indirect
127
145
github.com/prometheus/client_model v0.6.2 // indirect
128
-
github.com/prometheus/common v0.63.0 // indirect
146
+
github.com/prometheus/common v0.64.0 // indirect
129
147
github.com/prometheus/procfs v0.16.1 // indirect
148
+
github.com/ryanuber/go-glob v1.0.0 // indirect
130
149
github.com/segmentio/asm v1.2.0 // indirect
131
150
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
132
151
github.com/spaolacci/murmur3 v1.1.0 // indirect
···
136
155
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
137
156
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
138
157
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
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
143
163
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
144
164
go.uber.org/atomic v1.11.0 // indirect
145
165
go.uber.org/multierr v1.11.0 // indirect
146
166
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
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
154
174
google.golang.org/protobuf v1.36.6 // indirect
155
175
gopkg.in/fsnotify.v1 v1.4.7 // indirect
156
176
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+129
-87
go.sum
+129
-87
go.sum
···
7
7
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
8
8
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
9
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=
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
12
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
13
13
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
14
14
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
···
23
23
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
24
24
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
25
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=
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
28
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
29
29
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
30
30
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
51
51
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
52
52
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
53
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=
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=
56
58
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
57
59
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
58
60
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
···
91
93
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
92
94
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
93
95
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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
97
+
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
96
98
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
97
99
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
98
100
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
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=
101
104
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
102
105
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
103
106
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
···
114
117
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
115
118
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY=
116
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=
117
122
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
118
123
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=
124
+
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
125
+
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
121
126
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
122
127
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
123
128
github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0=
124
129
github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI=
125
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=
126
133
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
127
134
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
128
135
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
129
136
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
130
137
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=
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=
133
140
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
134
141
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
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=
137
145
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
138
146
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
139
147
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
···
146
154
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
147
155
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
148
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=
149
158
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
150
159
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
151
160
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
152
161
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
153
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=
154
165
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
155
166
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
156
167
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
166
177
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
167
178
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
168
179
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=
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=
171
182
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
172
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=
173
187
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
174
188
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
175
189
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
176
190
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=
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=
179
201
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
180
202
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
181
203
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
182
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=
183
207
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
184
208
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
185
209
github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw=
···
189
213
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
190
214
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
191
215
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=
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=
196
220
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
197
221
github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
198
222
github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U=
···
205
229
github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo=
206
230
github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0=
207
231
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=
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=
212
236
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
213
237
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
214
238
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
···
216
240
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
217
241
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
218
242
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
243
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
222
244
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
223
245
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
···
229
251
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
230
252
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
231
253
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=
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=
234
256
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
235
257
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
236
258
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
···
239
261
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
240
262
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
241
263
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=
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=
244
266
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
245
267
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
246
268
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
···
251
273
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
252
274
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
253
275
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=
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=
260
278
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
261
279
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
262
280
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
···
265
283
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
266
284
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
267
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=
268
288
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
269
289
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
270
290
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
···
281
301
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
282
302
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
283
303
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
304
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
287
305
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
306
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
291
307
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
292
308
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
···
318
334
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
319
335
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
320
336
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=
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=
323
341
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
324
342
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
325
343
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
326
344
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
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=
329
348
github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU=
330
349
github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
331
350
github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo=
···
346
365
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
347
366
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
348
367
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=
368
+
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
369
+
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
351
370
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
352
371
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
353
372
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=
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=
356
375
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
357
376
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
358
377
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
···
360
379
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
361
380
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
362
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=
363
384
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
364
385
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
365
386
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
···
404
425
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
405
426
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
406
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=
407
429
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
408
430
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
409
431
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
···
413
435
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
414
436
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
415
437
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=
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=
422
444
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU=
423
445
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=
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=
432
454
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
433
455
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
434
456
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
···
451
473
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
452
474
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
453
475
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=
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=
458
481
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
459
482
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
460
483
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
461
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=
462
486
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
463
487
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
464
488
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
465
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=
466
491
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
467
492
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
468
493
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
···
471
496
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
472
497
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
473
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=
474
500
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
475
501
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
476
502
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
···
480
506
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
481
507
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
482
508
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=
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=
485
513
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
486
514
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
487
515
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
489
517
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
490
518
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
491
519
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=
520
+
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
521
+
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
494
522
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
495
523
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
496
524
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
502
530
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
503
531
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
504
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=
505
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=
506
536
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
507
537
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
508
538
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
510
540
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
511
541
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
512
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=
513
544
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
514
545
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
515
546
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
516
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=
517
549
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=
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=
520
554
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
521
555
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
522
556
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
523
557
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
524
558
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
525
559
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=
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=
528
565
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
529
566
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
530
567
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
···
532
569
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
533
570
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
534
571
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=
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=
539
579
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
540
580
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
541
581
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
547
587
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
548
588
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
549
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=
550
591
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
551
592
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
552
593
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
553
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=
554
596
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
555
597
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
556
598
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
557
599
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
558
600
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
559
601
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=
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=
566
608
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
567
609
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
568
610
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
···
599
641
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
600
642
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
601
643
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=
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=
604
646
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
605
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
2
3
3
import (
4
4
"context"
5
+
"errors"
5
6
"fmt"
7
+
"io"
6
8
"log/slog"
7
9
"net/http"
8
10
"net/url"
···
13
15
"github.com/bluesky-social/indigo/atproto/identity"
14
16
securejoin "github.com/cyphar/filepath-securejoin"
15
17
"github.com/urfave/cli/v3"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
18
+
"tangled.sh/tangled.sh/core/idresolver"
17
19
"tangled.sh/tangled.sh/core/log"
18
20
)
19
21
···
43
45
Usage: "internal API endpoint",
44
46
Value: "http://localhost:5444",
45
47
},
48
+
&cli.StringFlag{
49
+
Name: "motd-file",
50
+
Usage: "path to message of the day file",
51
+
Value: "/home/git/motd",
52
+
},
46
53
},
47
54
}
48
55
}
···
54
61
gitDir := cmd.String("git-dir")
55
62
logPath := cmd.String("log-path")
56
63
endpoint := cmd.String("internal-api")
64
+
motdFile := cmd.String("motd-file")
57
65
58
66
logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
59
67
if err != nil {
···
149
157
"fullPath", fullPath,
150
158
"client", clientIP)
151
159
152
-
if gitCommand == "git-upload-pack" {
153
-
fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!")
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")
154
166
} else {
155
-
fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!")
167
+
motdReader = reader
168
+
}
169
+
if gitCommand == "git-upload-pack" {
170
+
io.WriteString(os.Stderr, "\x02")
156
171
}
172
+
io.Copy(os.Stderr, motdReader)
157
173
158
174
gitCmd := exec.Command(gitCommand, fullPath)
159
175
gitCmd.Stdout = os.Stdout
+24
hook/hook.go
+24
hook/hook.go
···
3
3
import (
4
4
"bufio"
5
5
"context"
6
+
"encoding/json"
6
7
"fmt"
7
8
"net/http"
8
9
"os"
···
10
11
11
12
"github.com/urfave/cli/v3"
12
13
)
14
+
15
+
type HookResponse struct {
16
+
Messages []string `json:"messages"`
17
+
}
13
18
14
19
// The hook command is nested like so:
15
20
//
···
36
41
Usage: "endpoint for the internal API",
37
42
Value: "http://localhost:5444",
38
43
},
44
+
&cli.StringSliceFlag{
45
+
Name: "push-option",
46
+
Usage: "any push option from git",
47
+
},
39
48
},
40
49
Commands: []*cli.Command{
41
50
{
···
52
61
userDid := cmd.String("user-did")
53
62
userHandle := cmd.String("user-handle")
54
63
endpoint := cmd.String("internal-api")
64
+
pushOptions := cmd.StringSlice("push-option")
55
65
56
66
payloadReader := bufio.NewReader(os.Stdin)
57
67
payload, _ := payloadReader.ReadString('\n')
···
67
77
req.Header.Set("X-Git-Dir", gitDir)
68
78
req.Header.Set("X-Git-User-Did", userDid)
69
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
+
}
70
85
71
86
resp, err := client.Do(req)
72
87
if err != nil {
···
76
91
77
92
if resp.StatusCode != http.StatusOK {
78
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)
79
103
}
80
104
81
105
return nil
+6
-1
hook/setup.go
+6
-1
hook/setup.go
···
133
133
134
134
hookContent := fmt.Sprintf(`#!/usr/bin/env bash
135
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
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
137
142
`, executablePath, config.internalApi)
138
143
139
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
52
j.mu.Unlock()
53
53
}
54
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
+
55
68
type processor func(context.Context, *models.Event) error
56
69
57
70
func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
+6
knotserver/config/config.go
+6
knotserver/config/config.go
···
2
2
3
3
import (
4
4
"context"
5
+
"fmt"
5
6
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
6
8
"github.com/sethvargo/go-envconfig"
7
9
)
8
10
···
23
25
24
26
// This disables signature verification so use with caution.
25
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))
26
32
}
27
33
28
34
type Config struct {
-8
knotserver/file.go
-8
knotserver/file.go
···
10
10
"tangled.sh/tangled.sh/core/types"
11
11
)
12
12
13
-
func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) {
14
-
data["files"] = files
15
-
16
-
writeJSON(w, data)
17
-
return
18
-
}
19
-
20
13
func countLines(r io.Reader) (int, error) {
21
14
buf := make([]byte, 32*1024)
22
15
bufLen := 0
···
52
45
53
46
resp.Lines = lc
54
47
writeJSON(w, resp)
55
-
return
56
48
}
+37
-18
knotserver/handler.go
+37
-18
knotserver/handler.go
···
8
8
"runtime/debug"
9
9
10
10
"github.com/go-chi/chi/v5"
11
+
"tangled.sh/tangled.sh/core/idresolver"
11
12
"tangled.sh/tangled.sh/core/jetstream"
12
13
"tangled.sh/tangled.sh/core/knotserver/config"
13
14
"tangled.sh/tangled.sh/core/knotserver/db"
15
+
"tangled.sh/tangled.sh/core/knotserver/xrpc"
16
+
tlog "tangled.sh/tangled.sh/core/log"
14
17
"tangled.sh/tangled.sh/core/notifier"
15
18
"tangled.sh/tangled.sh/core/rbac"
16
-
)
17
-
18
-
const (
19
-
ThisServer = "thisserver" // resource identifier for rbac enforcement
20
19
)
21
20
22
21
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
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
29
30
30
// init is a channel that is closed when the knot has been initailized
31
31
// i.e. when the first user (knot owner) has been added.
···
37
37
r := chi.NewRouter()
38
38
39
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{}),
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{}),
47
48
}
48
49
49
-
err := e.AddKnot(ThisServer)
50
+
err := e.AddKnot(rbac.ThisServer)
50
51
if err != nil {
51
52
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
52
53
}
···
131
132
})
132
133
})
133
134
135
+
// xrpc apis
136
+
r.Mount("/xrpc", h.XrpcRouter())
137
+
134
138
// Create a new repository.
135
139
r.Route("/repo", func(r chi.Router) {
136
140
r.Use(h.VerifySignature)
···
161
165
r.Get("/keys", h.Keys)
162
166
163
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()
164
183
}
165
184
166
185
// version is set during build time.
+65
-4
knotserver/ingester.go
+65
-4
knotserver/ingester.go
···
17
17
"github.com/bluesky-social/jetstream/pkg/models"
18
18
securejoin "github.com/cyphar/filepath-securejoin"
19
19
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
21
"tangled.sh/tangled.sh/core/knotserver/db"
22
22
"tangled.sh/tangled.sh/core/knotserver/git"
23
23
"tangled.sh/tangled.sh/core/log"
24
+
"tangled.sh/tangled.sh/core/rbac"
24
25
"tangled.sh/tangled.sh/core/workflow"
25
26
)
26
27
···
46
47
return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname)
47
48
}
48
49
49
-
ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite")
50
+
ok, err := h.e.E.Enforce(did, rbac.ThisServer, rbac.ThisServer, "server:invite")
50
51
if err != nil || !ok {
51
52
l.Error("failed to add member", "did", did)
52
53
return fmt.Errorf("failed to enforce permissions: %w", err)
53
54
}
54
55
55
-
if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil {
56
+
if err := h.e.AddKnotMember(rbac.ThisServer, record.Subject); err != nil {
56
57
l.Error("failed to add member", "error", err)
57
58
return fmt.Errorf("failed to add member: %w", err)
58
59
}
···
212
213
return h.db.InsertEvent(event, h.n)
213
214
}
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
+
215
266
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
216
267
l := log.FromContext(ctx)
217
268
···
265
316
defer func() {
266
317
eventTime := event.TimeUS
267
318
lastTimeUs := eventTime + 1
268
-
fmt.Println("lastTimeUs", lastTimeUs)
269
319
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
270
320
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
271
321
}
···
291
341
if err := h.processKnotMember(ctx, did, record); err != nil {
292
342
return fmt.Errorf("failed to process knot member: %w", err)
293
343
}
344
+
294
345
case tangled.RepoPullNSID:
295
346
var record tangled.RepoPull
296
347
if err := json.Unmarshal(raw, &record); err != nil {
···
299
350
if err := h.processPull(ctx, did, record); err != nil {
300
351
return fmt.Errorf("failed to process knot member: %w", err)
301
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
+
302
363
}
303
364
304
365
return err
+62
-5
knotserver/internal.go
+62
-5
knotserver/internal.go
···
13
13
"github.com/go-chi/chi/v5"
14
14
"github.com/go-chi/chi/v5/middleware"
15
15
"tangled.sh/tangled.sh/core/api/tangled"
16
+
"tangled.sh/tangled.sh/core/hook"
16
17
"tangled.sh/tangled.sh/core/knotserver/config"
17
18
"tangled.sh/tangled.sh/core/knotserver/db"
18
19
"tangled.sh/tangled.sh/core/knotserver/git"
···
38
39
return
39
40
}
40
41
41
-
ok, err := h.e.IsPushAllowed(user, ThisServer, repo)
42
+
ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
42
43
if err != nil || !ok {
43
44
w.WriteHeader(http.StatusForbidden)
44
45
return
···
64
65
return
65
66
}
66
67
68
+
type PushOptions struct {
69
+
skipCi bool
70
+
verboseCi bool
71
+
}
72
+
67
73
func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
68
74
l := h.l.With("handler", "PostReceiveHook")
69
75
···
90
96
// non-fatal
91
97
}
92
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
+
93
115
for _, line := range lines {
94
116
err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
95
117
if err != nil {
···
97
119
// non-fatal
98
120
}
99
121
100
-
err = h.triggerPipeline(line, gitUserDid, repoDid, repoName)
122
+
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
101
123
if err != nil {
102
124
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
103
125
// non-fatal
104
126
}
105
127
}
128
+
129
+
writeJSON(w, resp)
106
130
}
107
131
108
132
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
···
148
172
return h.db.InsertEvent(event, h.n)
149
173
}
150
174
151
-
func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
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
+
152
180
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
153
181
if err != nil {
154
182
return err
···
169
197
return err
170
198
}
171
199
200
+
pipelineParseErrors := []string{}
201
+
172
202
var pipeline workflow.Pipeline
173
203
for _, e := range workflowDir {
174
204
if !e.IsFile {
···
183
213
184
214
wf, err := workflow.FromFile(e.Name, contents)
185
215
if err != nil {
186
-
// TODO: log here, respond to client that is pushing
187
216
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
217
+
pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err))
188
218
continue
189
219
}
190
220
···
209
239
},
210
240
}
211
241
212
-
// TODO: send the diagnostics back to the user here via stderr
213
242
cp := compiler.Compile(pipeline)
214
243
eventJson, err := json.Marshal(cp)
215
244
if err != nil {
216
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
+
}
217
274
}
218
275
219
276
// do not run empty pipelines
+17
-8
knotserver/routes.go
+17
-8
knotserver/routes.go
···
29
29
"tangled.sh/tangled.sh/core/knotserver/db"
30
30
"tangled.sh/tangled.sh/core/knotserver/git"
31
31
"tangled.sh/tangled.sh/core/patchutil"
32
+
"tangled.sh/tangled.sh/core/rbac"
32
33
"tangled.sh/tangled.sh/core/types"
33
34
)
34
35
···
285
286
mimeType = "image/svg+xml"
286
287
}
287
288
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)
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)
291
300
return
292
301
}
293
302
···
674
683
}
675
684
676
685
// add perms for this user to access the repo
677
-
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
686
+
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
678
687
if err != nil {
679
688
l.Error("adding repo permissions", "error", err.Error())
680
689
writeError(w, err.Error(), http.StatusInternalServerError)
···
892
901
}
893
902
894
903
// add perms for this user to access the repo
895
-
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
904
+
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
896
905
if err != nil {
897
906
l.Error("adding repo permissions", "error", err.Error())
898
907
writeError(w, err.Error(), http.StatusInternalServerError)
···
1146
1155
}
1147
1156
h.jc.AddDid(did)
1148
1157
1149
-
if err := h.e.AddKnotMember(ThisServer, did); err != nil {
1158
+
if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil {
1150
1159
l.Error("adding member", "error", err.Error())
1151
1160
writeError(w, err.Error(), http.StatusInternalServerError)
1152
1161
return
···
1184
1193
h.jc.AddDid(data.Did)
1185
1194
1186
1195
repoName, _ := securejoin.SecureJoin(ownerDid, repo)
1187
-
if err := h.e.AddCollaborator(data.Did, ThisServer, repoName); err != nil {
1196
+
if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil {
1188
1197
l.Error("adding repo collaborator", "error", err.Error())
1189
1198
writeError(w, err.Error(), http.StatusInternalServerError)
1190
1199
return
···
1281
1290
}
1282
1291
h.jc.AddDid(data.Did)
1283
1292
1284
-
if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil {
1293
+
if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil {
1285
1294
l.Error("adding owner", "error", err.Error())
1286
1295
writeError(w, err.Error(), http.StatusInternalServerError)
1287
1296
return
+1
knotserver/server.go
+1
knotserver/server.go
-5
knotserver/util.go
-5
knotserver/util.go
···
8
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
9
securejoin "github.com/cyphar/filepath-securejoin"
10
10
"github.com/go-chi/chi/v5"
11
-
"github.com/microcosm-cc/bluemonday"
12
11
)
13
-
14
-
func sanitize(content []byte) []byte {
15
-
return bluemonday.UGCPolicy().SanitizeBytes([]byte(content))
16
-
}
17
12
18
13
func didPath(r *http.Request) string {
19
14
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
11
version = "v0.6.2"
12
12
hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU="
13
13
[mod."github.com/ProtonMail/go-crypto"]
14
-
version = "v1.2.0"
15
-
hash = "sha256-5fKgWUz6BoyFNNZ1OD9QjhBrhNEBCuVfO2WqH+X59oo="
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="
16
19
[mod."github.com/alecthomas/chroma/v2"]
17
20
version = "v2.19.0"
18
21
hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM="
19
22
replaced = "github.com/oppiliappan/chroma/v2"
23
+
[mod."github.com/alecthomas/repr"]
24
+
version = "v0.4.0"
25
+
hash = "sha256-CyAzMSTfLGHDtfGXi91y7XMVpPUDNOKjsznb+osl9dU="
20
26
[mod."github.com/anmitsu/go-shlex"]
21
27
version = "v0.0.0-20200514113438-38f4b401e2be"
22
28
hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54="
···
34
40
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
35
41
replaced = "tangled.sh/oppi.li/go-gitdiff"
36
42
[mod."github.com/bluesky-social/indigo"]
37
-
version = "v0.0.0-20250520232546-236dd575c91e"
38
-
hash = "sha256-SmwhGkAKcB/oGwYP68U5192fAUhui6D0GWYiJOeB1/0="
43
+
version = "v0.0.0-20250724221105-5827c8fb61bb"
44
+
hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI="
39
45
[mod."github.com/bluesky-social/jetstream"]
40
46
version = "v0.0.0-20241210005130-ea96859b93d1"
41
47
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
···
51
57
[mod."github.com/casbin/govaluate"]
52
58
version = "v1.3.0"
53
59
hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA="
60
+
[mod."github.com/cenkalti/backoff/v4"]
61
+
version = "v4.3.0"
62
+
hash = "sha256-wfVjNZsGG1WoNC5aL+kdcy6QXPgZo4THAevZ1787md8="
54
63
[mod."github.com/cespare/xxhash/v2"]
55
64
version = "v2.3.0"
56
65
hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY="
57
66
[mod."github.com/cloudflare/circl"]
58
-
version = "v1.6.0"
59
-
hash = "sha256-a+SVfnHYC8Fb+NQLboNg5P9sry+WutzuNetVHFVAAo0="
67
+
version = "v1.6.2-0.20250618153321-aa837fd1539d"
68
+
hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y="
60
69
[mod."github.com/containerd/errdefs"]
61
70
version = "v1.0.0"
62
71
hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI="
···
105
114
[mod."github.com/felixge/httpsnoop"]
106
115
version = "v1.0.4"
107
116
hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c="
117
+
[mod."github.com/fsnotify/fsnotify"]
118
+
version = "v1.6.0"
119
+
hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0="
108
120
[mod."github.com/gliderlabs/ssh"]
109
121
version = "v0.3.8"
110
122
hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc="
···
127
139
version = "v5.17.0"
128
140
hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ="
129
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="
130
145
[mod."github.com/go-logr/logr"]
131
-
version = "v1.4.2"
132
-
hash = "sha256-/W6qGilFlZNTb9Uq48xGZ4IbsVeSwJiAMLw4wiNYHLI="
146
+
version = "v1.4.3"
147
+
hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA="
133
148
[mod."github.com/go-logr/stdr"]
134
149
version = "v1.2.2"
135
150
hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE="
136
151
[mod."github.com/go-redis/cache/v9"]
137
152
version = "v9.0.0"
138
153
hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY="
154
+
[mod."github.com/go-test/deep"]
155
+
version = "v1.1.1"
156
+
hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8="
139
157
[mod."github.com/goccy/go-json"]
140
158
version = "v0.10.5"
141
159
hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw="
···
143
161
version = "v1.3.2"
144
162
hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c="
145
163
[mod."github.com/golang-jwt/jwt/v5"]
146
-
version = "v5.2.2"
147
-
hash = "sha256-C0MhDguxWR6dQUrNVQ5xaFUReSV6CVEBAijG3b4wnX4="
164
+
version = "v5.2.3"
165
+
hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo="
148
166
[mod."github.com/golang/groupcache"]
149
167
version = "v0.0.0-20241129210726-2c02b8208cf8"
150
168
hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74="
169
+
[mod."github.com/golang/mock"]
170
+
version = "v1.6.0"
171
+
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
151
172
[mod."github.com/google/uuid"]
152
173
version = "v1.6.0"
153
174
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
···
161
182
version = "v1.4.0"
162
183
hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g="
163
184
[mod."github.com/gorilla/websocket"]
164
-
version = "v1.5.3"
165
-
hash = "sha256-vTIGEFMEi+30ZdO6ffMNJ/kId6pZs5bbyqov8xe9BM0="
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="
166
190
[mod."github.com/hashicorp/go-cleanhttp"]
167
191
version = "v0.5.2"
168
192
hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ="
193
+
[mod."github.com/hashicorp/go-multierror"]
194
+
version = "v1.1.1"
195
+
hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA="
169
196
[mod."github.com/hashicorp/go-retryablehttp"]
170
-
version = "v0.7.7"
171
-
hash = "sha256-XZjxncyLPwy6YBHR3DF5bEl1y72or0JDUncTIsb/eIU="
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="
172
208
[mod."github.com/hashicorp/golang-lru"]
173
209
version = "v1.0.2"
174
210
hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="
175
211
[mod."github.com/hashicorp/golang-lru/v2"]
176
212
version = "v2.0.7"
177
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="
178
220
[mod."github.com/hiddeco/sshsig"]
179
221
version = "v0.2.0"
180
222
hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU="
···
185
227
version = "v0.0.4"
186
228
hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU="
187
229
[mod."github.com/ipfs/boxo"]
188
-
version = "v0.30.0"
189
-
hash = "sha256-PWH+nlIZZlqB/PuiBX9X4McLZF4gKR1MEnjvutKT848="
230
+
version = "v0.33.0"
231
+
hash = "sha256-C85D/TjyoWNKvRFvl2A9hBjpDPhC//hGB2jRoDmXM38="
190
232
[mod."github.com/ipfs/go-block-format"]
191
-
version = "v0.2.1"
192
-
hash = "sha256-npEV0Axe6zJlzN00/GwiegE9HKsuDR6RhsAfPyphOl8="
233
+
version = "v0.2.2"
234
+
hash = "sha256-kz87tlGneqEARGnjA1Lb0K5CqD1lgxhBD68rfmzZZGU="
193
235
[mod."github.com/ipfs/go-cid"]
194
236
version = "v0.5.0"
195
237
hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk="
···
203
245
version = "v1.1.1"
204
246
hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY="
205
247
[mod."github.com/ipfs/go-ipld-cbor"]
206
-
version = "v0.2.0"
207
-
hash = "sha256-bvHFCIQqim3/+xzl1bld3NxKY8WoeCO3HpdTfUsXvlc="
248
+
version = "v0.2.1"
249
+
hash = "sha256-ONBX/YO/knnmp+12fC13KsKVeo/vdWOI3SDyqCBxRE4="
208
250
[mod."github.com/ipfs/go-ipld-format"]
209
-
version = "v0.6.1"
210
-
hash = "sha256-v1zLYYGaoDxsgOW5joQGWHEHZoJjIXc6tLVgTomZ2z4="
251
+
version = "v0.6.2"
252
+
hash = "sha256-nGLM/n/hy+0q1VIzQUvF4D3aKvL238ALIEziQQhVMkU="
211
253
[mod."github.com/ipfs/go-log"]
212
254
version = "v1.0.5"
213
255
hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4="
···
224
266
version = "v1.18.0"
225
267
hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk="
226
268
[mod."github.com/klauspost/cpuid/v2"]
227
-
version = "v2.2.10"
228
-
hash = "sha256-o21Tk5sD7WhhLUoqSkymnjLbzxl0mDJCTC1ApfZJrC0="
269
+
version = "v2.3.0"
270
+
hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc="
229
271
[mod."github.com/lestrrat-go/blackmagic"]
230
-
version = "v1.0.3"
231
-
hash = "sha256-1wyfD6fPopJF/UmzfAEa0N1zuUzVuHIpdcxks1kqxxw="
272
+
version = "v1.0.4"
273
+
hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8="
232
274
[mod."github.com/lestrrat-go/httpcc"]
233
275
version = "v1.0.1"
234
276
hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos="
···
256
298
[mod."github.com/minio/sha256-simd"]
257
299
version = "v1.0.1"
258
300
hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA="
301
+
[mod."github.com/mitchellh/mapstructure"]
302
+
version = "v1.5.0"
303
+
hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE="
259
304
[mod."github.com/moby/docker-image-spec"]
260
305
version = "v1.3.1"
261
306
hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs="
···
289
334
[mod."github.com/munnerz/goautoneg"]
290
335
version = "v0.0.0-20191010083416-a7dc8b61c822"
291
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="
292
343
[mod."github.com/opencontainers/go-digest"]
293
344
version = "v1.0.0"
294
345
hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ="
···
296
347
version = "v1.1.1"
297
348
hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8="
298
349
[mod."github.com/opentracing/opentracing-go"]
299
-
version = "v1.2.0"
300
-
hash = "sha256-kKTKFGXOsCF6QdVzI++GgaRzv2W+kWq5uDXOJChvLxM="
350
+
version = "v1.2.1-0.20220228012449-10b1cf09e00b"
351
+
hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw="
301
352
[mod."github.com/pjbgf/sha1cd"]
302
353
version = "v0.3.2"
303
354
hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk="
···
320
371
version = "v0.6.2"
321
372
hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ="
322
373
[mod."github.com/prometheus/common"]
323
-
version = "v0.63.0"
324
-
hash = "sha256-TbUZNkN4ZA7eC/MlL1v2V5OL28QRnftSuaWQZ944zBE="
374
+
version = "v0.64.0"
375
+
hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI="
325
376
[mod."github.com/prometheus/procfs"]
326
377
version = "v0.16.1"
327
378
hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo="
328
379
[mod."github.com/redis/go-redis/v9"]
329
-
version = "v9.3.0"
330
-
hash = "sha256-PNXDX3BH92d2jL/AkdK0eWMorh387Y6duwYNhsqNe+w="
380
+
version = "v9.7.3"
381
+
hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo="
331
382
[mod."github.com/resend/resend-go/v2"]
332
383
version = "v2.15.0"
333
384
hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg="
385
+
[mod."github.com/ryanuber/go-glob"]
386
+
version = "v1.0.0"
387
+
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
334
388
[mod."github.com/segmentio/asm"]
335
389
version = "v1.2.0"
336
390
hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs="
···
375
429
version = "v1.1.0"
376
430
hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
377
431
[mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"]
378
-
version = "v0.61.0"
379
-
hash = "sha256-4pfXD7ErXhexSynXiEEQSAkWoPwHd7PEDE3M1Zi5gLM="
432
+
version = "v0.62.0"
433
+
hash = "sha256-WcDogpsvFxGOVqc+/ljlZ10nrOU2q0rPteukGyWWmfc="
380
434
[mod."go.opentelemetry.io/otel"]
381
-
version = "v1.36.0"
382
-
hash = "sha256-j8wojdCtKal3LKojanHA8KXXQ0FkbWONpO8tUxpJDko="
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="
383
440
[mod."go.opentelemetry.io/otel/metric"]
384
-
version = "v1.36.0"
385
-
hash = "sha256-z6Uqi4HhUljWIYd58svKK5MqcGbpcac+/M8JeTrUtJ8="
441
+
version = "v1.37.0"
442
+
hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg="
386
443
[mod."go.opentelemetry.io/otel/trace"]
387
-
version = "v1.36.0"
388
-
hash = "sha256-owWD9x1lp8aIJqYt058BXPUsIMHdk3RI0escso0BxwA="
444
+
version = "v1.37.0"
445
+
hash = "sha256-FBeLOb5qmIiE9VmbgCf1l/xpndBqHkRiaPt1PvoKrVY="
389
446
[mod."go.opentelemetry.io/proto/otlp"]
390
447
version = "v1.6.0"
391
448
hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg="
···
399
456
version = "v1.27.0"
400
457
hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU="
401
458
[mod."golang.org/x/crypto"]
402
-
version = "v0.38.0"
403
-
hash = "sha256-5tTXlXQBlfW1sSNDAIalOpsERbTJlZqbwCIiih4T4rY="
459
+
version = "v0.40.0"
460
+
hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng="
404
461
[mod."golang.org/x/exp"]
405
-
version = "v0.0.0-20250408133849-7e4ce0ab07d0"
406
-
hash = "sha256-Lw/WupSM8gcq0JzPSAaBqj9l1uZ68ANhaIaQzPhRpy8="
462
+
version = "v0.0.0-20250620022241-b7579e27df2b"
463
+
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
407
464
[mod."golang.org/x/net"]
408
-
version = "v0.40.0"
409
-
hash = "sha256-BhDOHTP8RekXDQDf9HlORSmI2aPacLo53fRXtTgCUH8="
465
+
version = "v0.42.0"
466
+
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
410
467
[mod."golang.org/x/sync"]
411
-
version = "v0.14.0"
412
-
hash = "sha256-YNQLeFMeXN9y0z4OyXV/LJ4hA54q+ljm1ytcy80O6r4="
468
+
version = "v0.16.0"
469
+
hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks="
413
470
[mod."golang.org/x/sys"]
414
-
version = "v0.33.0"
415
-
hash = "sha256-wlOzIOUgAiGAtdzhW/KPl/yUVSH/lvFZfs5XOuJ9LOQ="
471
+
version = "v0.34.0"
472
+
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
473
+
[mod."golang.org/x/text"]
474
+
version = "v0.27.0"
475
+
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
416
476
[mod."golang.org/x/time"]
417
-
version = "v0.8.0"
418
-
hash = "sha256-EA+qRisDJDPQ2g4pcfP4RyQaB7CJKkAn68EbNfBzXdQ="
477
+
version = "v0.12.0"
478
+
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
419
479
[mod."golang.org/x/xerrors"]
420
480
version = "v0.0.0-20240903120638-7835f813f4da"
421
481
hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo="
422
482
[mod."google.golang.org/genproto/googleapis/api"]
423
-
version = "v0.0.0-20250519155744-55703ea1f237"
424
-
hash = "sha256-ivktx8ipWgWZgchh4FjKoWL7kU8kl/TtIavtZq/F5SQ="
483
+
version = "v0.0.0-20250603155806-513f23925822"
484
+
hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU="
425
485
[mod."google.golang.org/genproto/googleapis/rpc"]
426
-
version = "v0.0.0-20250519155744-55703ea1f237"
486
+
version = "v0.0.0-20250603155806-513f23925822"
427
487
hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM="
428
488
[mod."google.golang.org/grpc"]
429
-
version = "v1.72.1"
430
-
hash = "sha256-5JczomNvroKWtIYKDgXwaIaEfuNEK//MHPhJQiaxMXs="
489
+
version = "v1.73.0"
490
+
hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c="
431
491
[mod."google.golang.org/protobuf"]
432
492
version = "v1.36.6"
433
493
hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc="
···
450
510
version = "v1.4.1"
451
511
hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
452
512
[mod."tangled.sh/icyphox.sh/atproto-oauth"]
453
-
version = "v0.0.0-20250526154904-3906c5336421"
454
-
hash = "sha256-CvR8jic0YZfj0a8ubPj06FiMMR/1K9kHoZhLQw1LItM="
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}: {
1
+
{
2
2
config,
3
-
pkgs,
4
3
lib,
5
4
...
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";
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
+
};
24
30
};
25
31
};
26
-
};
27
32
28
-
config = mkIf config.services.tangled-appview.enable {
29
-
systemd.services.tangled-appview = {
30
-
description = "tangled appview service";
31
-
wantedBy = ["multi-user.target"];
33
+
config = mkIf cfg.enable {
34
+
systemd.services.tangled-appview = {
35
+
description = "tangled appview service";
36
+
wantedBy = ["multi-user.target"];
32
37
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
+
serviceConfig = {
39
+
ListenStream = "0.0.0.0:${toString cfg.port}";
40
+
ExecStart = "${cfg.package}/bin/appview";
41
+
Restart = "always";
42
+
};
38
43
39
-
environment = {
40
-
TANGLED_DB_PATH = "appview.db";
41
-
TANGLED_COOKIE_SECRET = config.services.tangled-appview.cookie_secret;
44
+
environment = {
45
+
TANGLED_DB_PATH = "appview.db";
46
+
TANGLED_COOKIE_SECRET = cfg.cookie_secret;
47
+
};
42
48
};
43
49
};
44
-
};
45
-
}
50
+
}
+45
-7
nix/modules/knot.nix
+45
-7
nix/modules/knot.nix
···
1
-
{self}: {
1
+
{
2
2
config,
3
3
pkgs,
4
4
lib,
···
13
13
type = types.bool;
14
14
default = false;
15
15
description = "Enable a tangled knot";
16
+
};
17
+
18
+
package = mkOption {
19
+
type = types.package;
20
+
description = "Package to use for the knot";
16
21
};
17
22
18
23
appviewEndpoint = mkOption {
···
53
58
};
54
59
};
55
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
+
56
83
server = {
57
84
listenAddr = mkOption {
58
85
type = types.str;
···
94
121
};
95
122
96
123
config = mkIf cfg.enable {
97
-
environment.systemPackages = with pkgs; [
98
-
git
99
-
self.packages."${pkgs.system}".knot
124
+
environment.systemPackages = [
125
+
pkgs.git
126
+
cfg.package
100
127
];
101
128
102
-
system.activationScripts.gitConfig = ''
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 ''
103
138
mkdir -p "${cfg.repo.scanPath}"
104
139
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
105
140
···
108
143
[user]
109
144
name = Git User
110
145
email = git@example.com
146
+
[receive]
147
+
advertisePushOptions = true
111
148
EOF
149
+
${setMotd}
112
150
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
113
151
'';
114
152
···
135
173
mode = "0555";
136
174
text = ''
137
175
#!${pkgs.stdenv.shell}
138
-
${self.packages.${pkgs.system}.knot}/bin/knot keys \
176
+
${cfg.package}/bin/knot keys \
139
177
-output authorized-keys \
140
178
-internal-api "http://${cfg.server.internalListenAddr}" \
141
179
-git-dir "${cfg.repo.scanPath}" \
···
160
198
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
161
199
];
162
200
EnvironmentFile = cfg.server.secretFile;
163
-
ExecStart = "${self.packages.${pkgs.system}.knot}/bin/knot server";
201
+
ExecStart = "${cfg.package}/bin/knot server";
164
202
Restart = "always";
165
203
};
166
204
};
+6
-3
nix/modules/spindle.nix
+6
-3
nix/modules/spindle.nix
···
1
-
{self}: {
1
+
{
2
2
config,
3
-
pkgs,
4
3
lib,
5
4
...
6
5
}: let
···
13
12
type = types.bool;
14
13
default = false;
15
14
description = "Enable a tangled spindle";
15
+
};
16
+
package = mkOption {
17
+
type = types.package;
18
+
description = "Package to use for the spindle";
16
19
};
17
20
18
21
server = {
···
89
92
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
90
93
"SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
91
94
];
92
-
ExecStart = "${self.packages.${pkgs.system}.spindle}/bin/spindle";
95
+
ExecStart = "${cfg.package}/bin/spindle";
93
96
Restart = "always";
94
97
};
95
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
2
3
3
import (
4
4
"context"
5
+
"fmt"
5
6
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
6
8
"github.com/sethvargo/go-envconfig"
7
9
)
8
10
9
11
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"`
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"`
16
33
}
17
34
18
35
type Pipelines struct {
+42
-19
spindle/engine/engine.go
+42
-19
spindle/engine/engine.go
···
11
11
"sync"
12
12
"time"
13
13
14
+
securejoin "github.com/cyphar/filepath-securejoin"
14
15
"github.com/docker/docker/api/types/container"
15
16
"github.com/docker/docker/api/types/image"
16
17
"github.com/docker/docker/api/types/mount"
···
18
19
"github.com/docker/docker/api/types/volume"
19
20
"github.com/docker/docker/client"
20
21
"github.com/docker/docker/pkg/stdcopy"
22
+
"golang.org/x/sync/errgroup"
21
23
"tangled.sh/tangled.sh/core/log"
22
24
"tangled.sh/tangled.sh/core/notifier"
23
25
"tangled.sh/tangled.sh/core/spindle/config"
24
26
"tangled.sh/tangled.sh/core/spindle/db"
25
27
"tangled.sh/tangled.sh/core/spindle/models"
28
+
"tangled.sh/tangled.sh/core/spindle/secrets"
26
29
)
27
30
28
31
const (
···
37
40
db *db.DB
38
41
n *notifier.Notifier
39
42
cfg *config.Config
43
+
vault secrets.Manager
40
44
41
45
cleanupMu sync.Mutex
42
46
cleanup map[string][]cleanupFunc
43
47
}
44
48
45
-
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) {
49
+
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) {
46
50
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
47
51
if err != nil {
48
52
return nil, err
···
56
60
db: db,
57
61
n: n,
58
62
cfg: cfg,
63
+
vault: vault,
59
64
}
60
65
61
66
e.cleanup = make(map[string][]cleanupFunc)
···
66
71
func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
67
72
e.l.Info("starting all workflows in parallel", "pipeline", pipelineId)
68
73
69
-
wg := sync.WaitGroup{}
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)
70
91
for _, w := range pipeline.Workflows {
71
-
wg.Add(1)
72
-
go func() error {
73
-
defer wg.Done()
92
+
eg.Go(func() error {
74
93
wid := models.WorkflowId{
75
94
PipelineId: pipelineId,
76
95
Name: w.Name,
···
102
121
defer reader.Close()
103
122
io.Copy(os.Stdout, reader)
104
123
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
124
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
113
125
defer cancel()
114
126
115
-
err = e.StartSteps(ctx, w.Steps, wid, w.Image)
127
+
err = e.StartSteps(ctx, wid, w, allSecrets)
116
128
if err != nil {
117
129
if errors.Is(err, ErrTimedOut) {
118
130
dbErr := e.db.StatusTimeout(wid, e.n)
···
135
147
}
136
148
137
149
return nil
138
-
}()
150
+
})
139
151
}
140
152
141
-
wg.Wait()
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
+
}
142
158
}
143
159
144
160
// SetupWorkflow sets up a new network for the workflow and volumes for
···
186
202
// ONLY marks pipeline as failed if container's exit code is non-zero.
187
203
// All other errors are bubbled up.
188
204
// Fixed version of the step execution logic
189
-
func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error {
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
+
}
190
210
191
-
for stepIdx, step := range steps {
211
+
for stepIdx, step := range w.Steps {
192
212
select {
193
213
case <-ctx.Done():
194
214
return ctx.Err()
195
215
default:
196
216
}
197
217
198
-
envs := ConstructEnvs(step.Environment)
218
+
envs := append(EnvVars(nil), workflowEnvs...)
219
+
for k, v := range step.Environment {
220
+
envs.AddEnv(k, v)
221
+
}
199
222
envs.AddEnv("HOME", workspaceDir)
200
223
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
201
224
202
225
hostConfig := hostConfig(wid)
203
226
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
204
-
Image: image,
227
+
Image: w.Image,
205
228
Cmd: []string{"bash", "-c", step.Command},
206
229
WorkingDir: workspaceDir,
207
230
Tty: false,
+129
-2
spindle/ingester.go
+129
-2
spindle/ingester.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
8
8
9
"tangled.sh/tangled.sh/core/api/tangled"
9
10
"tangled.sh/tangled.sh/core/eventconsumer"
11
+
"tangled.sh/tangled.sh/core/idresolver"
12
+
"tangled.sh/tangled.sh/core/rbac"
10
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"
11
18
"github.com/bluesky-social/jetstream/pkg/models"
19
+
securejoin "github.com/cyphar/filepath-securejoin"
12
20
)
13
21
14
22
type Ingester func(ctx context.Context, e *models.Event) error
···
33
41
s.ingestMember(ctx, e)
34
42
case tangled.RepoNSID:
35
43
s.ingestRepo(ctx, e)
44
+
case tangled.RepoCollaboratorNSID:
45
+
s.ingestCollaborator(ctx, e)
36
46
}
37
47
38
48
return err
···
72
82
return fmt.Errorf("failed to enforce permissions: %w", err)
73
83
}
74
84
75
-
if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil {
85
+
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
76
86
l.Error("failed to add member", "error", err)
77
87
return fmt.Errorf("failed to add member: %w", err)
78
88
}
···
90
100
return nil
91
101
}
92
102
93
-
func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error {
103
+
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
94
104
var err error
105
+
did := e.Did
106
+
resolver := idresolver.DefaultResolver()
95
107
96
108
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
97
109
···
127
139
return fmt.Errorf("failed to add repo: %w", err)
128
140
}
129
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
+
130
162
// add this knot to the event consumer
131
163
src := eventconsumer.NewKnotSource(record.Knot)
132
164
s.ks.AddSource(context.Background(), src)
···
136
168
}
137
169
return nil
138
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
8
)
9
9
10
10
type Pipeline struct {
11
+
RepoOwner string
12
+
RepoName string
11
13
Workflows []Workflow
12
14
}
13
15
···
63
65
swf.Environment = workflowEnvToMap(twf.Environment)
64
66
swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery)
65
67
66
-
swf.addNixProfileToPath()
67
-
swf.setGlobalEnvs()
68
68
setup := &setupSteps{}
69
69
70
70
setup.addStep(nixConfStep())
···
79
79
80
80
workflows = append(workflows, *swf)
81
81
}
82
-
return &Pipeline{Workflows: workflows}
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
+
}
83
89
}
84
90
85
91
func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
···
115
121
116
122
return path.Join(nixery, dependencies)
117
123
}
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
-
}
+3
spindle/models/setup_steps.go
+3
spindle/models/setup_steps.go
···
102
102
continue
103
103
}
104
104
105
+
if len(packages) == 0 {
106
+
customPackages = append(customPackages, registry)
107
+
}
105
108
// collect packages from custom registries
106
109
for _, pkg := range packages {
107
110
customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg))
+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
2
3
3
import (
4
4
"context"
5
+
_ "embed"
5
6
"encoding/json"
6
7
"fmt"
7
8
"log/slog"
···
11
12
"tangled.sh/tangled.sh/core/api/tangled"
12
13
"tangled.sh/tangled.sh/core/eventconsumer"
13
14
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
15
+
"tangled.sh/tangled.sh/core/idresolver"
14
16
"tangled.sh/tangled.sh/core/jetstream"
15
17
"tangled.sh/tangled.sh/core/log"
16
18
"tangled.sh/tangled.sh/core/notifier"
···
20
22
"tangled.sh/tangled.sh/core/spindle/engine"
21
23
"tangled.sh/tangled.sh/core/spindle/models"
22
24
"tangled.sh/tangled.sh/core/spindle/queue"
25
+
"tangled.sh/tangled.sh/core/spindle/secrets"
26
+
"tangled.sh/tangled.sh/core/spindle/xrpc"
23
27
)
24
28
29
+
//go:embed motd
30
+
var motd []byte
31
+
25
32
const (
26
33
rbacDomain = "thisserver"
27
34
)
28
35
29
36
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
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
39
48
}
40
49
41
50
func Run(ctx context.Context) error {
···
59
68
60
69
n := notifier.New()
61
70
62
-
eng, err := engine.New(ctx, cfg, d, &n)
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)
63
97
if err != nil {
64
98
return err
65
99
}
···
69
103
collections := []string{
70
104
tangled.SpindleMemberNSID,
71
105
tangled.RepoNSID,
106
+
tangled.RepoCollaboratorNSID,
72
107
}
73
108
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
74
109
if err != nil {
···
76
111
}
77
112
jc.AddDid(cfg.Server.Owner)
78
113
114
+
resolver := idresolver.DefaultResolver()
115
+
79
116
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,
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,
88
127
}
89
128
90
129
err = e.AddSpindle(rbacDomain)
···
100
139
// starts a job queue runner in the background
101
140
jq.Start()
102
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
+
}
103
147
104
148
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
105
149
if err != nil {
···
144
188
mux := chi.NewRouter()
145
189
146
190
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`))
191
+
w.Write(motd)
171
192
})
172
193
mux.HandleFunc("/events", s.Events)
173
194
mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) {
174
195
w.Write([]byte(s.cfg.Server.Owner))
175
196
})
176
197
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
198
+
199
+
mux.Mount("/xrpc", s.XrpcRouter())
177
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()
178
217
}
179
218
180
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
+
}