+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
+
}
+21
-5
appview/config/config.go
+21
-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"`
19
+
20
+
// temporarily, to add users to default spindle
21
+
AppPassword string `env:"APP_PASSWORD"`
18
22
}
19
23
20
24
type OAuthConfig struct {
···
59
63
DB int `env:"DB, default=0"`
60
64
}
61
65
66
+
type PdsConfig struct {
67
+
Host string `env:"HOST, default=https://tngl.sh"`
68
+
AdminSecret string `env:"ADMIN_SECRET"`
69
+
}
70
+
71
+
type Cloudflare struct {
72
+
ApiToken string `env:"API_TOKEN"`
73
+
ZoneId string `env:"ZONE_ID"`
74
+
}
75
+
62
76
func (cfg RedisConfig) ToURL() string {
63
77
u := &url.URL{
64
78
Scheme: "redis",
···
84
98
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
85
99
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
86
100
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
101
+
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
102
+
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
87
103
}
88
104
89
105
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
+
}
+82
-5
appview/db/db.go
+82
-5
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
···
652
728
kind := rv.Kind()
653
729
654
730
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
655
-
if kind == reflect.Slice || kind == reflect.Array {
731
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || 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())
···
671
748
func (f filter) Arg() []any {
672
749
rv := reflect.ValueOf(f.arg)
673
750
kind := rv.Kind()
674
-
if kind == reflect.Slice || kind == reflect.Array {
751
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || 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
}
-62
appview/db/migrations/20250305_113405.sql
-62
appview/db/migrations/20250305_113405.sql
···
1
-
-- Simplified SQLite Database Migration Script for Issues and Comments
2
-
3
-
-- Migration for issues table
4
-
CREATE TABLE issues_new (
5
-
id integer primary key autoincrement,
6
-
owner_did text not null,
7
-
repo_at text not null,
8
-
issue_id integer not null,
9
-
title text not null,
10
-
body text not null,
11
-
open integer not null default 1,
12
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
13
-
issue_at text,
14
-
unique(repo_at, issue_id),
15
-
foreign key (repo_at) references repos(at_uri) on delete cascade
16
-
);
17
-
18
-
-- Migrate data to new issues table
19
-
INSERT INTO issues_new (
20
-
id, owner_did, repo_at, issue_id,
21
-
title, body, open, created, issue_at
22
-
)
23
-
SELECT
24
-
id, owner_did, repo_at, issue_id,
25
-
title, body, open, created, issue_at
26
-
FROM issues;
27
-
28
-
-- Drop old issues table
29
-
DROP TABLE issues;
30
-
31
-
-- Rename new issues table
32
-
ALTER TABLE issues_new RENAME TO issues;
33
-
34
-
-- Migration for comments table
35
-
CREATE TABLE comments_new (
36
-
id integer primary key autoincrement,
37
-
owner_did text not null,
38
-
issue_id integer not null,
39
-
repo_at text not null,
40
-
comment_id integer not null,
41
-
comment_at text not null,
42
-
body text not null,
43
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
44
-
unique(issue_id, comment_id),
45
-
foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade
46
-
);
47
-
48
-
-- Migrate data to new comments table
49
-
INSERT INTO comments_new (
50
-
id, owner_did, issue_id, repo_at,
51
-
comment_id, comment_at, body, created
52
-
)
53
-
SELECT
54
-
id, owner_did, issue_id, repo_at,
55
-
comment_id, comment_at, body, created
56
-
FROM comments;
57
-
58
-
-- Drop old comments table
59
-
DROP TABLE comments;
60
-
61
-
-- Rename new comments table
62
-
ALTER TABLE comments_new RENAME TO comments;
-66
appview/db/migrations/validate.sql
-66
appview/db/migrations/validate.sql
···
1
-
-- Validation Queries for Database Migration
2
-
3
-
-- 1. Verify Issues Table Structure
4
-
PRAGMA table_info(issues);
5
-
6
-
-- 2. Verify Comments Table Structure
7
-
PRAGMA table_info(comments);
8
-
9
-
-- 3. Check Total Row Count Consistency
10
-
SELECT
11
-
'Issues Row Count' AS check_type,
12
-
(SELECT COUNT(*) FROM issues) AS row_count
13
-
UNION ALL
14
-
SELECT
15
-
'Comments Row Count' AS check_type,
16
-
(SELECT COUNT(*) FROM comments) AS row_count;
17
-
18
-
-- 4. Verify Unique Constraint on Issues
19
-
SELECT
20
-
repo_at,
21
-
issue_id,
22
-
COUNT(*) as duplicate_count
23
-
FROM issues
24
-
GROUP BY repo_at, issue_id
25
-
HAVING duplicate_count > 1;
26
-
27
-
-- 5. Verify Foreign Key Integrity for Comments
28
-
SELECT
29
-
'Orphaned Comments' AS check_type,
30
-
COUNT(*) AS orphaned_count
31
-
FROM comments c
32
-
LEFT JOIN issues i ON c.repo_at = i.repo_at AND c.issue_id = i.issue_id
33
-
WHERE i.id IS NULL;
34
-
35
-
-- 6. Check Foreign Key Constraint
36
-
PRAGMA foreign_key_list(comments);
37
-
38
-
-- 7. Sample Data Integrity Check
39
-
SELECT
40
-
'Sample Issues' AS check_type,
41
-
repo_at,
42
-
issue_id,
43
-
title,
44
-
created
45
-
FROM issues
46
-
LIMIT 5;
47
-
48
-
-- 8. Sample Comments Data Integrity Check
49
-
SELECT
50
-
'Sample Comments' AS check_type,
51
-
repo_at,
52
-
issue_id,
53
-
comment_id,
54
-
body,
55
-
created
56
-
FROM comments
57
-
LIMIT 5;
58
-
59
-
-- 9. Verify Constraint on Comments (Issue ID and Comment ID Uniqueness)
60
-
SELECT
61
-
issue_id,
62
-
comment_id,
63
-
COUNT(*) as duplicate_count
64
-
FROM comments
65
-
GROUP BY issue_id, comment_id
66
-
HAVING duplicate_count > 1;
+2
-36
appview/db/repos.go
+2
-36
appview/db/repos.go
···
391
391
var description, spindle sql.NullString
392
392
393
393
row := e.QueryRow(`
394
-
select did, name, knot, created, at_uri, description, spindle
394
+
select did, name, knot, created, at_uri, description, spindle
395
395
from repos
396
396
where did = ? and name = ?
397
397
`,
···
550
550
return &repo, nil
551
551
}
552
552
553
-
func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error {
554
-
_, err := e.Exec(
555
-
`insert into collaborators (did, repo)
556
-
values (?, (select id from repos where did = ? and name = ? and knot = ?));`,
557
-
collaborator, repoOwnerDid, repoName, repoKnot)
558
-
return err
559
-
}
560
-
561
553
func UpdateDescription(e Execer, repoAt, newDescription string) error {
562
554
_, err := e.Exec(
563
555
`update repos set description = ? where at_uri = ?`, newDescription, repoAt)
564
556
return err
565
557
}
566
558
567
-
func UpdateSpindle(e Execer, repoAt, spindle string) error {
559
+
func UpdateSpindle(e Execer, repoAt string, spindle *string) error {
568
560
_, err := e.Exec(
569
561
`update repos set spindle = ? where at_uri = ?`, spindle, repoAt)
570
562
return err
571
-
}
572
-
573
-
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
574
-
rows, err := e.Query(`select repo from collaborators where did = ?`, collaborator)
575
-
if err != nil {
576
-
return nil, err
577
-
}
578
-
defer rows.Close()
579
-
580
-
var repoIds []int
581
-
for rows.Next() {
582
-
var id int
583
-
err := rows.Scan(&id)
584
-
if err != nil {
585
-
return nil, err
586
-
}
587
-
repoIds = append(repoIds, id)
588
-
}
589
-
if err := rows.Err(); err != nil {
590
-
return nil, err
591
-
}
592
-
if repoIds == nil {
593
-
return nil, nil
594
-
}
595
-
596
-
return GetRepos(e, 0, FilterIn("id", repoIds))
597
563
}
598
564
599
565
type RepoStats struct {
+29
appview/db/signup.go
+29
appview/db/signup.go
···
1
+
package db
2
+
3
+
import "time"
4
+
5
+
type InflightSignup struct {
6
+
Id int64
7
+
Email string
8
+
InviteCode string
9
+
Created time.Time
10
+
}
11
+
12
+
func AddInflightSignup(e Execer, signup InflightSignup) error {
13
+
query := `insert into signups_inflight (email, invite_code) values (?, ?)`
14
+
_, err := e.Exec(query, signup.Email, signup.InviteCode)
15
+
return err
16
+
}
17
+
18
+
func DeleteInflightSignup(e Execer, email string) error {
19
+
query := `delete from signups_inflight where email = ?`
20
+
_, err := e.Exec(query, email)
21
+
return err
22
+
}
23
+
24
+
func GetEmailForCode(e Execer, inviteCode string) (string, error) {
25
+
query := `select email from signups_inflight where invite_code = ?`
26
+
var email string
27
+
err := e.QueryRow(query, inviteCode).Scan(&email)
28
+
return email, err
29
+
}
+7
-2
appview/db/star.go
+7
-2
appview/db/star.go
···
33
33
return nil
34
34
}
35
35
36
-
func AddStar(e Execer, starredByDid string, repoAt syntax.ATURI, rkey string) error {
36
+
func AddStar(e Execer, star *Star) error {
37
37
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
38
-
_, err := e.Exec(query, starredByDid, repoAt, rkey)
38
+
_, err := e.Exec(
39
+
query,
40
+
star.StarredByDid,
41
+
star.RepoAt.String(),
42
+
star.Rkey,
43
+
)
39
44
return err
40
45
}
41
46
+251
appview/db/strings.go
+251
appview/db/strings.go
···
1
+
package db
2
+
3
+
import (
4
+
"bytes"
5
+
"database/sql"
6
+
"errors"
7
+
"fmt"
8
+
"io"
9
+
"strings"
10
+
"time"
11
+
"unicode/utf8"
12
+
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
"tangled.sh/tangled.sh/core/api/tangled"
15
+
)
16
+
17
+
type String struct {
18
+
Did syntax.DID
19
+
Rkey string
20
+
21
+
Filename string
22
+
Description string
23
+
Contents string
24
+
Created time.Time
25
+
Edited *time.Time
26
+
}
27
+
28
+
func (s *String) StringAt() syntax.ATURI {
29
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
30
+
}
31
+
32
+
type StringStats struct {
33
+
LineCount uint64
34
+
ByteCount uint64
35
+
}
36
+
37
+
func (s String) Stats() StringStats {
38
+
lineCount, err := countLines(strings.NewReader(s.Contents))
39
+
if err != nil {
40
+
// non-fatal
41
+
// TODO: log this?
42
+
}
43
+
44
+
return StringStats{
45
+
LineCount: uint64(lineCount),
46
+
ByteCount: uint64(len(s.Contents)),
47
+
}
48
+
}
49
+
50
+
func (s String) Validate() error {
51
+
var err error
52
+
53
+
if !strings.Contains(s.Filename, ".") {
54
+
err = errors.Join(err, fmt.Errorf("missing filename extension"))
55
+
}
56
+
57
+
if strings.HasSuffix(s.Filename, ".") {
58
+
err = errors.Join(err, fmt.Errorf("filename ends with `.`"))
59
+
}
60
+
61
+
if utf8.RuneCountInString(s.Filename) > 140 {
62
+
err = errors.Join(err, fmt.Errorf("filename too long"))
63
+
}
64
+
65
+
if utf8.RuneCountInString(s.Description) > 280 {
66
+
err = errors.Join(err, fmt.Errorf("description too long"))
67
+
}
68
+
69
+
if len(s.Contents) == 0 {
70
+
err = errors.Join(err, fmt.Errorf("contents is empty"))
71
+
}
72
+
73
+
return err
74
+
}
75
+
76
+
func (s *String) AsRecord() tangled.String {
77
+
return tangled.String{
78
+
Filename: s.Filename,
79
+
Description: s.Description,
80
+
Contents: s.Contents,
81
+
CreatedAt: s.Created.Format(time.RFC3339),
82
+
}
83
+
}
84
+
85
+
func StringFromRecord(did, rkey string, record tangled.String) String {
86
+
created, err := time.Parse(record.CreatedAt, time.RFC3339)
87
+
if err != nil {
88
+
created = time.Now()
89
+
}
90
+
return String{
91
+
Did: syntax.DID(did),
92
+
Rkey: rkey,
93
+
Filename: record.Filename,
94
+
Description: record.Description,
95
+
Contents: record.Contents,
96
+
Created: created,
97
+
}
98
+
}
99
+
100
+
func AddString(e Execer, s String) error {
101
+
_, err := e.Exec(
102
+
`insert into strings (
103
+
did,
104
+
rkey,
105
+
filename,
106
+
description,
107
+
content,
108
+
created,
109
+
edited
110
+
)
111
+
values (?, ?, ?, ?, ?, ?, null)
112
+
on conflict(did, rkey) do update set
113
+
filename = excluded.filename,
114
+
description = excluded.description,
115
+
content = excluded.content,
116
+
edited = case
117
+
when
118
+
strings.content != excluded.content
119
+
or strings.filename != excluded.filename
120
+
or strings.description != excluded.description then ?
121
+
else strings.edited
122
+
end`,
123
+
s.Did,
124
+
s.Rkey,
125
+
s.Filename,
126
+
s.Description,
127
+
s.Contents,
128
+
s.Created.Format(time.RFC3339),
129
+
time.Now().Format(time.RFC3339),
130
+
)
131
+
return err
132
+
}
133
+
134
+
func GetStrings(e Execer, filters ...filter) ([]String, error) {
135
+
var all []String
136
+
137
+
var conditions []string
138
+
var args []any
139
+
for _, filter := range filters {
140
+
conditions = append(conditions, filter.Condition())
141
+
args = append(args, filter.Arg()...)
142
+
}
143
+
144
+
whereClause := ""
145
+
if conditions != nil {
146
+
whereClause = " where " + strings.Join(conditions, " and ")
147
+
}
148
+
149
+
query := fmt.Sprintf(`select
150
+
did,
151
+
rkey,
152
+
filename,
153
+
description,
154
+
content,
155
+
created,
156
+
edited
157
+
from strings %s`,
158
+
whereClause,
159
+
)
160
+
161
+
rows, err := e.Query(query, args...)
162
+
163
+
if err != nil {
164
+
return nil, err
165
+
}
166
+
defer rows.Close()
167
+
168
+
for rows.Next() {
169
+
var s String
170
+
var createdAt string
171
+
var editedAt sql.NullString
172
+
173
+
if err := rows.Scan(
174
+
&s.Did,
175
+
&s.Rkey,
176
+
&s.Filename,
177
+
&s.Description,
178
+
&s.Contents,
179
+
&createdAt,
180
+
&editedAt,
181
+
); err != nil {
182
+
return nil, err
183
+
}
184
+
185
+
s.Created, err = time.Parse(time.RFC3339, createdAt)
186
+
if err != nil {
187
+
s.Created = time.Now()
188
+
}
189
+
190
+
if editedAt.Valid {
191
+
e, err := time.Parse(time.RFC3339, editedAt.String)
192
+
if err != nil {
193
+
e = time.Now()
194
+
}
195
+
s.Edited = &e
196
+
}
197
+
198
+
all = append(all, s)
199
+
}
200
+
201
+
if err := rows.Err(); err != nil {
202
+
return nil, err
203
+
}
204
+
205
+
return all, nil
206
+
}
207
+
208
+
func DeleteString(e Execer, filters ...filter) error {
209
+
var conditions []string
210
+
var args []any
211
+
for _, filter := range filters {
212
+
conditions = append(conditions, filter.Condition())
213
+
args = append(args, filter.Arg()...)
214
+
}
215
+
216
+
whereClause := ""
217
+
if conditions != nil {
218
+
whereClause = " where " + strings.Join(conditions, " and ")
219
+
}
220
+
221
+
query := fmt.Sprintf(`delete from strings %s`, whereClause)
222
+
223
+
_, err := e.Exec(query, args...)
224
+
return err
225
+
}
226
+
227
+
func countLines(r io.Reader) (int, error) {
228
+
buf := make([]byte, 32*1024)
229
+
bufLen := 0
230
+
count := 0
231
+
nl := []byte{'\n'}
232
+
233
+
for {
234
+
c, err := r.Read(buf)
235
+
if c > 0 {
236
+
bufLen += c
237
+
}
238
+
count += bytes.Count(buf[:c], nl)
239
+
240
+
switch {
241
+
case err == io.EOF:
242
+
/* handle last line not having a newline at the end */
243
+
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
244
+
count++
245
+
}
246
+
return count, nil
247
+
case err != nil:
248
+
return 0, err
249
+
}
250
+
}
251
+
}
+53
appview/dns/cloudflare.go
+53
appview/dns/cloudflare.go
···
1
+
package dns
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
7
+
"github.com/cloudflare/cloudflare-go"
8
+
"tangled.sh/tangled.sh/core/appview/config"
9
+
)
10
+
11
+
type Record struct {
12
+
Type string
13
+
Name string
14
+
Content string
15
+
TTL int
16
+
Proxied bool
17
+
}
18
+
19
+
type Cloudflare struct {
20
+
api *cloudflare.API
21
+
zone string
22
+
}
23
+
24
+
func NewCloudflare(c *config.Config) (*Cloudflare, error) {
25
+
apiToken := c.Cloudflare.ApiToken
26
+
api, err := cloudflare.NewWithAPIToken(apiToken)
27
+
if err != nil {
28
+
return nil, err
29
+
}
30
+
return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil
31
+
}
32
+
33
+
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error {
34
+
_, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
35
+
Type: record.Type,
36
+
Name: record.Name,
37
+
Content: record.Content,
38
+
TTL: record.TTL,
39
+
Proxied: &record.Proxied,
40
+
})
41
+
if err != nil {
42
+
return fmt.Errorf("failed to create DNS record: %w", err)
43
+
}
44
+
return nil
45
+
}
46
+
47
+
func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
48
+
err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID)
49
+
if err != nil {
50
+
return fmt.Errorf("failed to delete DNS record: %w", err)
51
+
}
52
+
return nil
53
+
}
-113
appview/idresolver/resolver.go
-113
appview/idresolver/resolver.go
···
1
-
package idresolver
2
-
3
-
import (
4
-
"context"
5
-
"net"
6
-
"net/http"
7
-
"sync"
8
-
"time"
9
-
10
-
"github.com/bluesky-social/indigo/atproto/identity"
11
-
"github.com/bluesky-social/indigo/atproto/identity/redisdir"
12
-
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"github.com/carlmjohnson/versioninfo"
14
-
"tangled.sh/tangled.sh/core/appview/config"
15
-
)
16
-
17
-
type Resolver struct {
18
-
directory identity.Directory
19
-
}
20
-
21
-
func BaseDirectory() identity.Directory {
22
-
base := identity.BaseDirectory{
23
-
PLCURL: identity.DefaultPLCURL,
24
-
HTTPClient: http.Client{
25
-
Timeout: time.Second * 10,
26
-
Transport: &http.Transport{
27
-
// would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad.
28
-
IdleConnTimeout: time.Millisecond * 1000,
29
-
MaxIdleConns: 100,
30
-
},
31
-
},
32
-
Resolver: net.Resolver{
33
-
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
34
-
d := net.Dialer{Timeout: time.Second * 3}
35
-
return d.DialContext(ctx, network, address)
36
-
},
37
-
},
38
-
TryAuthoritativeDNS: true,
39
-
// primary Bluesky PDS instance only supports HTTP resolution method
40
-
SkipDNSDomainSuffixes: []string{".bsky.social"},
41
-
UserAgent: "indigo-identity/" + versioninfo.Short(),
42
-
}
43
-
return &base
44
-
}
45
-
46
-
func RedisDirectory(url string) (identity.Directory, error) {
47
-
hitTTL := time.Hour * 24
48
-
errTTL := time.Second * 30
49
-
invalidHandleTTL := time.Minute * 5
50
-
return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
51
-
}
52
-
53
-
func DefaultResolver() *Resolver {
54
-
return &Resolver{
55
-
directory: identity.DefaultDirectory(),
56
-
}
57
-
}
58
-
59
-
func RedisResolver(config config.RedisConfig) (*Resolver, error) {
60
-
directory, err := RedisDirectory(config.ToURL())
61
-
if err != nil {
62
-
return nil, err
63
-
}
64
-
return &Resolver{
65
-
directory: directory,
66
-
}, nil
67
-
}
68
-
69
-
func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) {
70
-
id, err := syntax.ParseAtIdentifier(arg)
71
-
if err != nil {
72
-
return nil, err
73
-
}
74
-
75
-
return r.directory.Lookup(ctx, *id)
76
-
}
77
-
78
-
func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity {
79
-
results := make([]*identity.Identity, len(idents))
80
-
var wg sync.WaitGroup
81
-
82
-
done := make(chan struct{})
83
-
defer close(done)
84
-
85
-
for idx, ident := range idents {
86
-
wg.Add(1)
87
-
go func(index int, id string) {
88
-
defer wg.Done()
89
-
90
-
select {
91
-
case <-ctx.Done():
92
-
results[index] = nil
93
-
case <-done:
94
-
results[index] = nil
95
-
default:
96
-
identity, _ := r.ResolveIdent(ctx, id)
97
-
results[index] = identity
98
-
}
99
-
}(idx, ident)
100
-
}
101
-
102
-
wg.Wait()
103
-
return results
104
-
}
105
-
106
-
func (r *Resolver) InvalidateIdent(ctx context.Context, arg string) error {
107
-
id, err := syntax.ParseAtIdentifier(arg)
108
-
if err != nil {
109
-
return err
110
-
}
111
-
112
-
return r.directory.Purge(ctx, *id)
113
-
}
+81
-4
appview/ingester.go
+81
-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
}
···
378
387
if err != nil {
379
388
return fmt.Errorf("failed to update ACLs: %w", err)
380
389
}
390
+
391
+
l.Info("added spindle member")
381
392
case models.CommitOperationDelete:
382
393
rkey := e.Commit.RKey
383
394
···
424
435
if err = i.Enforcer.E.SavePolicy(); err != nil {
425
436
return fmt.Errorf("failed to save ACLs: %w", err)
426
437
}
438
+
439
+
l.Info("removed spindle member")
427
440
}
428
441
429
442
return nil
···
503
516
i.Enforcer.E.LoadPolicy()
504
517
}()
505
518
519
+
// remove spindle members first
520
+
err = db.RemoveSpindleMember(
521
+
tx,
522
+
db.FilterEq("owner", did),
523
+
db.FilterEq("instance", instance),
524
+
)
525
+
if err != nil {
526
+
return err
527
+
}
528
+
506
529
err = db.DeleteSpindle(
507
530
tx,
508
531
db.FilterEq("owner", did),
···
532
555
533
556
return nil
534
557
}
558
+
559
+
func (i *Ingester) ingestString(e *models.Event) error {
560
+
did := e.Did
561
+
rkey := e.Commit.RKey
562
+
563
+
var err error
564
+
565
+
l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
566
+
l.Info("ingesting record")
567
+
568
+
ddb, ok := i.Db.Execer.(*db.DB)
569
+
if !ok {
570
+
return fmt.Errorf("failed to index string record, invalid db cast")
571
+
}
572
+
573
+
switch e.Commit.Operation {
574
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
575
+
raw := json.RawMessage(e.Commit.Record)
576
+
record := tangled.String{}
577
+
err = json.Unmarshal(raw, &record)
578
+
if err != nil {
579
+
l.Error("invalid record", "err", err)
580
+
return err
581
+
}
582
+
583
+
string := db.StringFromRecord(did, rkey, record)
584
+
585
+
if err = string.Validate(); err != nil {
586
+
l.Error("invalid record", "err", err)
587
+
return err
588
+
}
589
+
590
+
if err = db.AddString(ddb, string); err != nil {
591
+
l.Error("failed to add string", "err", err)
592
+
return err
593
+
}
594
+
595
+
return nil
596
+
597
+
case models.CommitOperationDelete:
598
+
if err := db.DeleteString(
599
+
ddb,
600
+
db.FilterEq("did", did),
601
+
db.FilterEq("rkey", rkey),
602
+
); err != nil {
603
+
l.Error("failed to delete", "err", err)
604
+
return fmt.Errorf("failed to delete string record: %w", err)
605
+
}
606
+
607
+
return nil
608
+
}
609
+
610
+
return nil
611
+
}
+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(),
+4
-12
appview/middleware/middleware.go
+4
-12
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
···
188
178
return
189
179
}
190
180
181
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
182
+
191
183
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
192
184
if err != nil {
193
185
// invalid did or handle
194
-
log.Println("failed to resolve did/handle:", err)
186
+
log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err)
195
187
mw.pages.Error404(w)
196
188
return
197
189
}
+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) {}
+142
-1
appview/oauth/handler/handler.go
+142
-1
appview/oauth/handler/handler.go
···
1
1
package oauth
2
2
3
3
import (
4
+
"bytes"
5
+
"context"
4
6
"encoding/json"
5
7
"fmt"
6
8
"log"
7
9
"net/http"
8
10
"net/url"
9
11
"strings"
12
+
"time"
10
13
11
14
"github.com/go-chi/chi/v5"
12
15
"github.com/gorilla/sessions"
13
16
"github.com/lestrrat-go/jwx/v2/jwk"
14
17
"github.com/posthog/posthog-go"
15
18
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
19
+
tangled "tangled.sh/tangled.sh/core/api/tangled"
16
20
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
17
21
"tangled.sh/tangled.sh/core/appview/config"
18
22
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/idresolver"
20
23
"tangled.sh/tangled.sh/core/appview/middleware"
21
24
"tangled.sh/tangled.sh/core/appview/oauth"
22
25
"tangled.sh/tangled.sh/core/appview/oauth/client"
23
26
"tangled.sh/tangled.sh/core/appview/pages"
27
+
"tangled.sh/tangled.sh/core/idresolver"
24
28
"tangled.sh/tangled.sh/core/knotclient"
25
29
"tangled.sh/tangled.sh/core/rbac"
30
+
"tangled.sh/tangled.sh/core/tid"
26
31
)
27
32
28
33
const (
···
294
299
295
300
log.Println("session saved successfully")
296
301
go o.addToDefaultKnot(oauthRequest.Did)
302
+
go o.addToDefaultSpindle(oauthRequest.Did)
297
303
298
304
if !o.config.Core.Dev {
299
305
err = o.posthog.Enqueue(posthog.Capture{
···
330
336
return nil, err
331
337
}
332
338
return pubKey, nil
339
+
}
340
+
341
+
func (o *OAuthHandler) addToDefaultSpindle(did string) {
342
+
// use the tangled.sh app password to get an accessJwt
343
+
// and create an sh.tangled.spindle.member record with that
344
+
345
+
defaultSpindle := "spindle.tangled.sh"
346
+
appPassword := o.config.Core.AppPassword
347
+
348
+
spindleMembers, err := db.GetSpindleMembers(
349
+
o.db,
350
+
db.FilterEq("instance", "spindle.tangled.sh"),
351
+
db.FilterEq("subject", did),
352
+
)
353
+
if err != nil {
354
+
log.Printf("failed to get spindle members for did %s: %v", did, err)
355
+
return
356
+
}
357
+
358
+
if len(spindleMembers) != 0 {
359
+
log.Printf("did %s is already a member of the default spindle", did)
360
+
return
361
+
}
362
+
363
+
// TODO: hardcoded tangled handle and did for now
364
+
tangledHandle := "tangled.sh"
365
+
tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli"
366
+
367
+
if appPassword == "" {
368
+
log.Println("no app password configured, skipping spindle member addition")
369
+
return
370
+
}
371
+
372
+
log.Printf("adding %s to default spindle", did)
373
+
374
+
resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid)
375
+
if err != nil {
376
+
log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
377
+
return
378
+
}
379
+
380
+
pdsEndpoint := resolved.PDSEndpoint()
381
+
if pdsEndpoint == "" {
382
+
log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
383
+
return
384
+
}
385
+
386
+
sessionPayload := map[string]string{
387
+
"identifier": tangledHandle,
388
+
"password": appPassword,
389
+
}
390
+
sessionBytes, err := json.Marshal(sessionPayload)
391
+
if err != nil {
392
+
log.Printf("failed to marshal session payload: %v", err)
393
+
return
394
+
}
395
+
396
+
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
397
+
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
398
+
if err != nil {
399
+
log.Printf("failed to create session request: %v", err)
400
+
return
401
+
}
402
+
sessionReq.Header.Set("Content-Type", "application/json")
403
+
404
+
client := &http.Client{Timeout: 30 * time.Second}
405
+
sessionResp, err := client.Do(sessionReq)
406
+
if err != nil {
407
+
log.Printf("failed to create session: %v", err)
408
+
return
409
+
}
410
+
defer sessionResp.Body.Close()
411
+
412
+
if sessionResp.StatusCode != http.StatusOK {
413
+
log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode)
414
+
return
415
+
}
416
+
417
+
var session struct {
418
+
AccessJwt string `json:"accessJwt"`
419
+
}
420
+
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
421
+
log.Printf("failed to decode session response: %v", err)
422
+
return
423
+
}
424
+
425
+
record := tangled.SpindleMember{
426
+
LexiconTypeID: "sh.tangled.spindle.member",
427
+
Subject: did,
428
+
Instance: defaultSpindle,
429
+
CreatedAt: time.Now().Format(time.RFC3339),
430
+
}
431
+
432
+
recordBytes, err := json.Marshal(record)
433
+
if err != nil {
434
+
log.Printf("failed to marshal spindle member record: %v", err)
435
+
return
436
+
}
437
+
438
+
payload := map[string]interface{}{
439
+
"repo": tangledDid,
440
+
"collection": tangled.SpindleMemberNSID,
441
+
"rkey": tid.TID(),
442
+
"record": json.RawMessage(recordBytes),
443
+
}
444
+
445
+
payloadBytes, err := json.Marshal(payload)
446
+
if err != nil {
447
+
log.Printf("failed to marshal request payload: %v", err)
448
+
return
449
+
}
450
+
451
+
url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
452
+
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
453
+
if err != nil {
454
+
log.Printf("failed to create HTTP request: %v", err)
455
+
return
456
+
}
457
+
458
+
req.Header.Set("Content-Type", "application/json")
459
+
req.Header.Set("Authorization", "Bearer "+session.AccessJwt)
460
+
461
+
resp, err := client.Do(req)
462
+
if err != nil {
463
+
log.Printf("failed to add user to default spindle: %v", err)
464
+
return
465
+
}
466
+
defer resp.Body.Close()
467
+
468
+
if resp.StatusCode != http.StatusOK {
469
+
log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode)
470
+
return
471
+
}
472
+
473
+
log.Printf("successfully added %s to default spindle", did)
333
474
}
334
475
335
476
func (o *OAuthHandler) addToDefaultKnot(did string) {
+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 {
+4
-1
appview/pages/funcmap.go
+4
-1
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 {
···
236
236
},
237
237
"cssContentHash": CssContentHash,
238
238
"fileTree": filetree.FileTree,
239
+
"pathEscape": func(s string) string {
240
+
return url.PathEscape(s)
241
+
},
239
242
"pathUnescape": func(s string) string {
240
243
u, _ := url.PathUnescape(s)
241
244
return u
+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 }}
+19
-30
appview/pages/templates/layouts/base.html
+19
-30
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">
18
-
<div class="px-1" style="z-index: 20">
19
-
{{ block "topbarLayout" . }}
20
-
<div class="grid grid-cols-1 md:grid-cols-12">
21
-
<header class="col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;">
22
-
{{ template "layouts/topbar" . }}
23
-
</header>
24
-
</div>
25
-
{{ end }}
26
-
</div>
17
+
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
18
+
{{ block "topbarLayout" . }}
19
+
<header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;">
20
+
{{ template "layouts/topbar" . }}
21
+
</header>
22
+
{{ end }}
27
23
28
-
<div class="px-1 flex flex-col min-h-screen gap-4">
29
-
{{ block "contentLayout" . }}
30
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
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" . }}
31
27
<div class="col-span-1 md:col-span-2">
32
28
{{ block "contentLeft" . }} {{ end }}
33
29
</div>
···
37
33
<div class="col-span-1 md:col-span-2">
38
34
{{ block "contentRight" . }} {{ end }}
39
35
</div>
40
-
</div>
41
-
{{ end }}
42
-
43
-
{{ block "contentAfterLayout" . }}
44
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
36
+
{{ end }}
37
+
38
+
{{ block "contentAfterLayout" . }}
45
39
<div class="col-span-1 md:col-span-2">
46
40
{{ block "contentAfterLeft" . }} {{ end }}
47
41
</div>
···
51
45
<div class="col-span-1 md:col-span-2">
52
46
{{ block "contentAfterRight" . }} {{ end }}
53
47
</div>
54
-
</div>
55
-
{{ end }}
56
-
</div>
57
-
58
-
<div class="px-1 mt-16">
59
-
{{ block "footerLayout" . }}
60
-
<div class="grid grid-cols-1 md:grid-cols-12">
61
-
<footer class="col-span-1 md:col-start-3 md:col-span-8">
62
-
{{ template "layouts/footer" . }}
63
-
</footer>
48
+
{{ end }}
64
49
</div>
65
-
{{ end }}
66
-
</div>
50
+
{{ end }}
67
51
52
+
{{ block "footerLayout" . }}
53
+
<footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12">
54
+
{{ template "layouts/footer" . }}
55
+
</footer>
56
+
{{ end }}
68
57
</body>
69
58
</html>
70
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>
+39
-18
appview/pages/templates/layouts/topbar.html
+39
-18
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 nav-dropdown">
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
-
<details class="relative inline-block text-left">
45
+
<details class="relative inline-block text-left nav-dropdown">
38
46
<summary
39
47
class="cursor-pointer list-none flex items-center"
40
48
>
···
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>
···
56
66
</a>
57
67
</div>
58
68
</details>
69
+
70
+
<script>
71
+
document.addEventListener('click', function(event) {
72
+
const dropdowns = document.querySelectorAll('.nav-dropdown');
73
+
dropdowns.forEach(function(dropdown) {
74
+
if (!dropdown.contains(event.target)) {
75
+
dropdown.removeAttribute('open');
76
+
}
77
+
});
78
+
});
79
+
</script>
59
80
{{ end }}
+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 }}
+20
-14
appview/pages/templates/repo/commit.html
+20
-14
appview/pages/templates/repo/commit.html
···
80
80
{{end}}
81
81
82
82
{{ define "topbarLayout" }}
83
-
<header style="z-index: 20;">
83
+
<header class="px-1 col-span-full" style="z-index: 20;">
84
84
{{ template "layouts/topbar" . }}
85
85
</header>
86
86
{{ end }}
87
87
88
-
{{ define "contentLayout" }}
89
-
{{ block "content" . }}{{ end }}
90
-
{{ end }}
88
+
{{ define "mainLayout" }}
89
+
<div class="px-1 col-span-full flex flex-col gap-4">
90
+
{{ block "contentLayout" . }}
91
+
{{ block "content" . }}{{ end }}
92
+
{{ end }}
91
93
92
-
{{ define "contentAfterLayout" }}
93
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
94
-
<div class="col-span-1 md:col-span-2">
95
-
{{ block "contentAfterLeft" . }} {{ end }}
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>
96
102
</div>
97
-
<main class="col-span-1 md:col-span-10">
98
-
{{ block "contentAfter" . }}{{ end }}
99
-
</main>
103
+
{{ end }}
100
104
</div>
101
105
{{ end }}
102
106
103
-
{{ define "footerLayout" }}
104
-
{{ template "layouts/footer" . }}
107
+
{{ define "footerLayout" }}
108
+
<footer class="px-1 col-span-full mt-12">
109
+
{{ template "layouts/footer" . }}
110
+
</footer>
105
111
{{ end }}
106
112
107
113
{{ define "contentAfter" }}
···
112
118
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
113
119
{{ template "repo/fragments/diffOpts" .DiffOpts }}
114
120
</div>
115
-
<div class="sticky top-0 mt-4">
121
+
<div class="sticky top-0 flex-grow max-h-screen">
116
122
{{ template "repo/fragments/diffChangedFiles" .Diff }}
117
123
</div>
118
124
{{end}}
+22
-14
appview/pages/templates/repo/compare/compare.html
+22
-14
appview/pages/templates/repo/compare/compare.html
···
11
11
{{ end }}
12
12
13
13
{{ define "topbarLayout" }}
14
-
{{ template "layouts/topbar" . }}
14
+
<header class="px-1 col-span-full" style="z-index: 20;">
15
+
{{ template "layouts/topbar" . }}
16
+
</header>
15
17
{{ end }}
16
18
17
-
{{ define "contentLayout" }}
18
-
{{ block "content" . }}{{ end }}
19
-
{{ end }}
19
+
{{ define "mainLayout" }}
20
+
<div class="px-1 col-span-full flex flex-col gap-4">
21
+
{{ block "contentLayout" . }}
22
+
{{ block "content" . }}{{ end }}
23
+
{{ end }}
20
24
21
-
{{ define "contentAfterLayout" }}
22
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
23
-
<div class="col-span-1 md:col-span-2">
24
-
{{ block "contentAfterLeft" . }} {{ end }}
25
+
{{ 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>
25
33
</div>
26
-
<main class="col-span-1 md:col-span-10">
27
-
{{ block "contentAfter" . }}{{ end }}
28
-
</main>
34
+
{{ end }}
29
35
</div>
30
36
{{ end }}
31
37
32
-
{{ define "footerLayout" }}
33
-
{{ template "layouts/footer" . }}
38
+
{{ define "footerLayout" }}
39
+
<footer class="px-1 col-span-full mt-12">
40
+
{{ template "layouts/footer" . }}
41
+
</footer>
34
42
{{ end }}
35
43
36
44
{{ define "contentAfter" }}
···
41
49
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
42
50
{{ template "repo/fragments/diffOpts" .DiffOpts }}
43
51
</div>
44
-
<div class="sticky top-0 mt-4">
52
+
<div class="sticky top-0 flex-grow max-h-screen">
45
53
{{ template "repo/fragments/diffChangedFiles" .Diff }}
46
54
</div>
47
55
{{end}}
+17
-3
appview/pages/templates/repo/empty.html
+17
-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
+
36
+
<p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p>
37
+
<p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p>
38
+
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
39
+
<p><span class="{{$bullet}}">4</span>Push!</p>
40
+
</div>
41
+
</div>
26
42
{{ 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>
43
+
<p class="text-gray-400 dark:text-gray-500 py-6 text-center">This is an empty repository.</p>
30
44
{{ end }}
31
45
</main>
32
46
{{ 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>
+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 }}
+38
-70
appview/pages/templates/repo/index.html
+38
-70
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>
150
-
151
-
{{ if .LastCommit }}
152
-
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span>
153
-
{{ end }}
154
-
</div>
155
-
</div>
156
-
{{ end }}
157
-
{{ end }}
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" }}
158
139
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
···
387
355
{{ end }}"
388
356
>
389
357
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
390
-
{{- .HTMLReadme -}}
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
-1
appview/pages/templates/repo/pipelines/workflow.html
+5
-1
appview/pages/templates/repo/pipelines/workflow.html
···
19
19
20
20
{{ define "sidebar" }}
21
21
{{ $active := .Workflow }}
22
+
23
+
{{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }}
24
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }}
25
+
22
26
{{ with .Pipeline }}
23
27
{{ $id := .Id }}
24
28
<div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
25
29
{{ range $name, $all := .Statuses }}
26
30
<a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
27
31
<div
28
-
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}">
32
+
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
29
33
{{ $lastStatus := $all.Latest }}
30
34
{{ $kind := $lastStatus.Status.String }}
31
35
+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>
+22
-14
appview/pages/templates/repo/pulls/interdiff.html
+22
-14
appview/pages/templates/repo/pulls/interdiff.html
···
29
29
{{ end }}
30
30
31
31
{{ define "topbarLayout" }}
32
-
{{ template "layouts/topbar" . }}
32
+
<header class="px-1 col-span-full" style="z-index: 20;">
33
+
{{ template "layouts/topbar" . }}
34
+
</header>
33
35
{{ end }}
34
36
35
-
{{ define "contentLayout" }}
36
-
{{ block "content" . }}{{ end }}
37
-
{{ end }}
37
+
{{ define "mainLayout" }}
38
+
<div class="px-1 col-span-full flex flex-col gap-4">
39
+
{{ block "contentLayout" . }}
40
+
{{ block "content" . }}{{ end }}
41
+
{{ end }}
38
42
39
-
{{ define "contentAfterLayout" }}
40
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
41
-
<div class="col-span-1 md:col-span-2">
42
-
{{ block "contentAfterLeft" . }} {{ end }}
43
+
{{ 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>
43
51
</div>
44
-
<main class="col-span-1 md:col-span-10">
45
-
{{ block "contentAfter" . }}{{ end }}
46
-
</main>
52
+
{{ end }}
47
53
</div>
48
54
{{ end }}
49
55
50
-
{{ define "footerLayout" }}
51
-
{{ template "layouts/footer" . }}
56
+
{{ define "footerLayout" }}
57
+
<footer class="px-1 col-span-full mt-12">
58
+
{{ template "layouts/footer" . }}
59
+
</footer>
52
60
{{ end }}
53
61
54
62
···
60
68
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
61
69
{{ template "repo/fragments/diffOpts" .DiffOpts }}
62
70
</div>
63
-
<div class="sticky top-0 mt-4">
71
+
<div class="sticky top-0 flex-grow max-h-screen">
64
72
{{ template "repo/fragments/interdiffFiles" .Interdiff }}
65
73
</div>
66
74
{{end}}
+22
-14
appview/pages/templates/repo/pulls/patch.html
+22
-14
appview/pages/templates/repo/pulls/patch.html
···
35
35
{{ end }}
36
36
37
37
{{ define "topbarLayout" }}
38
-
{{ template "layouts/topbar" . }}
38
+
<header class="px-1 col-span-full" style="z-index: 20;">
39
+
{{ template "layouts/topbar" . }}
40
+
</header>
39
41
{{ end }}
40
42
41
-
{{ define "contentLayout" }}
42
-
{{ block "content" . }}{{ end }}
43
-
{{ end }}
43
+
{{ define "mainLayout" }}
44
+
<div class="px-1 col-span-full flex flex-col gap-4">
45
+
{{ block "contentLayout" . }}
46
+
{{ block "content" . }}{{ end }}
47
+
{{ end }}
44
48
45
-
{{ define "contentAfterLayout" }}
46
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
47
-
<div class="col-span-1 md:col-span-2">
48
-
{{ block "contentAfterLeft" . }} {{ end }}
49
+
{{ 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>
49
57
</div>
50
-
<main class="col-span-1 md:col-span-10">
51
-
{{ block "contentAfter" . }}{{ end }}
52
-
</main>
58
+
{{ end }}
53
59
</div>
54
60
{{ end }}
55
61
56
-
{{ define "footerLayout" }}
57
-
{{ template "layouts/footer" . }}
62
+
{{ define "footerLayout" }}
63
+
<footer class="px-1 col-span-full mt-12">
64
+
{{ template "layouts/footer" . }}
65
+
</footer>
58
66
{{ end }}
59
67
60
68
{{ define "contentAfter" }}
···
65
73
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
66
74
{{ template "repo/fragments/diffOpts" .DiffOpts }}
67
75
</div>
68
-
<div class="sticky top-0 mt-4">
76
+
<div class="sticky top-0 flex-grow max-h-screen">
69
77
{{ template "repo/fragments/diffChangedFiles" .Diff }}
70
78
</div>
71
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 }}
+145
appview/pages/templates/repo/settings/pipelines.html
+145
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
+
{{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}}
42
+
<option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}>
43
+
{{ if not $.CurrentSpindle }}
44
+
Choose a spindle
45
+
{{ else }}
46
+
Disable pipelines
47
+
{{ end }}
48
+
</option>
49
+
{{ range $.Spindles }}
50
+
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
51
+
{{ . }}
52
+
</option>
53
+
{{ end }}
54
+
</select>
55
+
<button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
56
+
{{ i "check" "size-4" }}
57
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
58
+
</button>
59
+
</form>
60
+
{{ end }}
61
+
</div>
62
+
{{ end }}
63
+
64
+
{{ define "secretSettings" }}
65
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
66
+
<div class="col-span-1 md:col-span-2">
67
+
<h2 class="text-sm pb-2 uppercase font-bold">SECRETS</h2>
68
+
<p class="text-gray-500 dark:text-gray-400">
69
+
Secrets are accessible in workflow runs via environment variables. Anyone
70
+
with collaborator access to this repository can add and use secrets in
71
+
workflow runs.
72
+
</p>
73
+
</div>
74
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
75
+
{{ template "addSecretButton" . }}
76
+
</div>
77
+
</div>
78
+
<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">
79
+
{{ range .Secrets }}
80
+
{{ template "repo/settings/fragments/secretListing" (list $ .) }}
81
+
{{ else }}
82
+
<div class="flex items-center justify-center p-2 text-gray-500">
83
+
no secrets added yet
84
+
</div>
85
+
{{ end }}
86
+
</div>
87
+
{{ end }}
88
+
89
+
{{ define "addSecretButton" }}
90
+
<button
91
+
class="btn flex items-center gap-2"
92
+
popovertarget="add-secret-modal"
93
+
popovertargetaction="toggle">
94
+
{{ i "plus" "size-4" }}
95
+
add secret
96
+
</button>
97
+
<div
98
+
id="add-secret-modal"
99
+
popover
100
+
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">
101
+
{{ template "addSecretModal" . }}
102
+
</div>
103
+
{{ end}}
104
+
105
+
{{ define "addSecretModal" }}
106
+
<form
107
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
108
+
hx-indicator="#spinner"
109
+
hx-swap="none"
110
+
class="flex flex-col gap-2"
111
+
>
112
+
<p class="uppercase p-0">ADD SECRET</p>
113
+
<p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p>
114
+
<input
115
+
type="text"
116
+
id="secret-key"
117
+
name="key"
118
+
required
119
+
placeholder="SECRET_NAME"
120
+
/>
121
+
<textarea
122
+
type="text"
123
+
id="secret-value"
124
+
name="value"
125
+
required
126
+
placeholder="secret value"></textarea>
127
+
<div class="flex gap-2 pt-2">
128
+
<button
129
+
type="button"
130
+
popovertarget="add-secret-modal"
131
+
popovertargetaction="hide"
132
+
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"
133
+
>
134
+
{{ i "x" "size-4" }} cancel
135
+
</button>
136
+
<button type="submit" class="btn w-1/2 flex items-center">
137
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
138
+
<span id="spinner" class="group">
139
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
140
+
</span>
141
+
</button>
142
+
</div>
143
+
<div id="add-secret-error" class="text-red-500 dark:text-red-400"></div>
144
+
</form>
145
+
{{ end }}
-138
appview/pages/templates/repo/settings.html
-138
appview/pages/templates/repo/settings.html
···
1
-
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
-
{{ define "repoContent" }}
3
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
4
-
Collaborators
5
-
</header>
6
-
7
-
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
8
-
{{ range .Collaborators }}
9
-
<div id="collaborator" class="mb-2">
10
-
<a
11
-
href="/{{ didOrHandle .Did .Handle }}"
12
-
class="no-underline hover:underline text-black dark:text-white"
13
-
>
14
-
{{ didOrHandle .Did .Handle }}
15
-
</a>
16
-
<div>
17
-
<span class="text-sm text-gray-500 dark:text-gray-400">
18
-
{{ .Role }}
19
-
</span>
20
-
</div>
21
-
</div>
22
-
{{ end }}
23
-
</div>
24
-
25
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
26
-
<form
27
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
28
-
class="group"
29
-
>
30
-
<label for="collaborator" class="dark:text-white">
31
-
add collaborator
32
-
</label>
33
-
<input
34
-
type="text"
35
-
id="collaborator"
36
-
name="collaborator"
37
-
required
38
-
class="dark:bg-gray-700 dark:text-white"
39
-
placeholder="enter did or handle"
40
-
>
41
-
<button
42
-
class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700"
43
-
type="text"
44
-
>
45
-
<span>add</span>
46
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
47
-
</button>
48
-
</form>
49
-
{{ end }}
50
-
51
-
<form
52
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default"
53
-
class="mt-6 group"
54
-
>
55
-
<label for="branch">default branch</label>
56
-
<div class="flex gap-2 items-center">
57
-
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
58
-
<option
59
-
value=""
60
-
disabled
61
-
selected
62
-
>
63
-
Choose a default branch
64
-
</option>
65
-
{{ range .Branches }}
66
-
<option
67
-
value="{{ .Name }}"
68
-
class="py-1"
69
-
{{ if .IsDefault }}
70
-
selected
71
-
{{ end }}
72
-
>
73
-
{{ .Name }}
74
-
</option>
75
-
{{ end }}
76
-
</select>
77
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
78
-
<span>save</span>
79
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
80
-
</button>
81
-
</div>
82
-
</form>
83
-
84
-
{{ if .RepoInfo.Roles.IsOwner }}
85
-
<form
86
-
hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle"
87
-
class="mt-6 group"
88
-
>
89
-
<label for="spindle">spindle</label>
90
-
<div class="flex gap-2 items-center">
91
-
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
92
-
<option
93
-
value=""
94
-
selected
95
-
>
96
-
None
97
-
</option>
98
-
{{ range .Spindles }}
99
-
<option
100
-
value="{{ . }}"
101
-
class="py-1"
102
-
{{ if eq . $.CurrentSpindle }}
103
-
selected
104
-
{{ end }}
105
-
>
106
-
{{ . }}
107
-
</option>
108
-
{{ end }}
109
-
</select>
110
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
111
-
<span>save</span>
112
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
113
-
</button>
114
-
</div>
115
-
</form>
116
-
{{ end }}
117
-
118
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
119
-
<form
120
-
hx-confirm="Are you sure you want to delete this repository?"
121
-
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
122
-
class="mt-6"
123
-
hx-indicator="#delete-repo-spinner"
124
-
>
125
-
<label for="branch">delete repository</label>
126
-
<button class="btn my-2 flex items-center" type="text">
127
-
<span>delete</span>
128
-
<span id="delete-repo-spinner" class="group">
129
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
130
-
</span>
131
-
</button>
132
-
<span>
133
-
Deleting a repository is irreversible and permanent.
134
-
</span>
135
-
</form>
136
-
{{ end }}
137
-
138
-
{{ end }}
+27
-34
appview/pages/templates/repo/tree.html
+27
-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 = "flex-shrink-0 size-4" }}
65
+
{{ end }}
66
+
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
+
<div class="flex items-center gap-2">
68
+
{{ i $icon $iconStyle }}
69
+
<span class="truncate">{{ .Name }}</span>
69
70
</div>
70
-
{{ end }}
71
+
</a>
72
+
</div>
73
+
74
+
<div class="col-span-0 md:col-span-7 hidden md:block overflow-hidden">
75
+
{{ with .LastCommit }}
76
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a>
77
+
{{ end }}
71
78
</div>
72
-
</div>
73
-
{{ end }}
74
-
{{ end }}
75
79
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 }}
80
+
<div class="col-span-6 md:col-span-2 text-right">
81
+
{{ with .LastCommit }}
82
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a>
83
+
{{ end }}
91
84
</div>
92
-
</div>
85
+
</div>
93
86
{{ end }}
94
-
{{ end }}
87
+
95
88
</div>
96
89
</main>
97
90
{{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 }}
+88
appview/pages/templates/strings/string.html
+88
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="/strings/{{ $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>
48
+
{{ with .String.Description }}
49
+
{{ . }}
50
+
{{ end }}
51
+
</span>
52
+
</section>
53
+
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
54
+
<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">
55
+
<span>
56
+
{{ .String.Filename }}
57
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
58
+
<span>
59
+
{{ with .String.Edited }}
60
+
edited {{ template "repo/fragments/shortTimeAgo" . }}
61
+
{{ else }}
62
+
{{ template "repo/fragments/shortTimeAgo" .String.Created }}
63
+
{{ end }}
64
+
</span>
65
+
</span>
66
+
<div>
67
+
<span>{{ .Stats.LineCount }} lines</span>
68
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
69
+
<span>{{ byteFmt .Stats.ByteCount }}</span>
70
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
71
+
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a>
72
+
{{ if .RenderToggle }}
73
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
74
+
<a href="?code={{ .ShowRendered }}" hx-boost="true">
75
+
view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}
76
+
</a>
77
+
{{ end }}
78
+
</div>
79
+
</div>
80
+
<div class="overflow-auto relative">
81
+
{{ if .ShowRendered }}
82
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
83
+
{{ else }}
84
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
85
+
{{ end }}
86
+
</div>
87
+
</section>
88
+
{{ end }}
+2
-2
appview/pages/templates/timeline.html
+2
-2
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>
+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 }}
+8
-7
appview/pages/templates/user/fragments/profileCard.html
+8
-7
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
-
<p title="{{ didOrHandle .UserDid .UserHandle }}"
13
-
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
14
-
{{ didOrHandle .UserDid .UserHandle }}
15
-
</p>
10
+
<div class="flex items-center flex-row flex-nowrap gap-2">
11
+
<p title="{{ didOrHandle .UserDid .UserHandle }}"
12
+
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
13
+
{{ didOrHandle .UserDid .UserHandle }}
14
+
</p>
15
+
<a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a>
16
+
</div>
16
17
17
18
<div class="md:hidden">
18
19
{{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
+5
-4
appview/pages/templates/user/fragments/repoCard.html
+5
-4
appview/pages/templates/user/fragments/repoCard.html
···
28
28
{{ define "repoStats" }}
29
29
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto">
30
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>
31
+
<div class="flex gap-2 items-center text-sm">
32
+
<div class="size-2 rounded-full"
33
+
style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div>
34
+
<span>{{ . }}</span>
35
+
</div>
35
36
{{ end }}
36
37
{{ with .StarCount }}
37
38
<div class="flex gap-1 items-center text-sm">
+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>
+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 ATProto 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
-41
appview/pulls/pulls.go
+44
-41
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
···
555
555
556
556
// we want to group all stacked PRs into just one list
557
557
stacks := make(map[string]db.Stack)
558
+
var shas []string
558
559
n := 0
559
560
for _, p := range pulls {
561
+
// store the sha for later
562
+
shas = append(shas, p.LatestSha())
560
563
// this PR is stacked
561
564
if p.StackId != "" {
562
565
// we have already seen this PR stack
···
575
578
}
576
579
pulls = pulls[:n]
577
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
+
578
598
identsToResolve := make([]string, len(pulls))
579
599
for i, pull := range pulls {
580
600
identsToResolve[i] = pull.OwnerDid
···
596
616
DidHandleMap: didHandleMap,
597
617
FilteringBy: state,
598
618
Stacks: stacks,
619
+
Pipelines: m,
599
620
})
600
-
return
601
621
}
602
622
603
623
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
···
668
688
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
669
689
Collection: tangled.RepoPullCommentNSID,
670
690
Repo: user.Did,
671
-
Rkey: appview.TID(),
691
+
Rkey: tid.TID(),
672
692
Record: &lexutil.LexiconTypeDecoder{
673
693
Val: &tangled.RepoPullComment{
674
694
Repo: &atUri,
···
685
705
return
686
706
}
687
707
688
-
// Create the pull comment in the database with the commentAt field
689
-
commentId, err := db.NewPullComment(tx, &db.PullComment{
708
+
comment := &db.PullComment{
690
709
OwnerDid: user.Did,
691
710
RepoAt: f.RepoAt.String(),
692
711
PullId: pull.PullId,
693
712
Body: body,
694
713
CommentAt: atResp.Uri,
695
714
SubmissionId: pull.Submissions[roundNumber].ID,
696
-
})
715
+
}
716
+
717
+
// Create the pull comment in the database with the commentAt field
718
+
commentId, err := db.NewPullComment(tx, comment)
697
719
if err != nil {
698
720
log.Println("failed to create pull comment", err)
699
721
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
707
729
return
708
730
}
709
731
710
-
if !s.config.Core.Dev {
711
-
err = s.posthog.Enqueue(posthog.Capture{
712
-
DistinctId: user.Did,
713
-
Event: "new_pull_comment",
714
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId},
715
-
})
716
-
if err != nil {
717
-
log.Println("failed to enqueue posthog event:", err)
718
-
}
719
-
}
732
+
s.notifier.NewPullComment(r.Context(), comment)
720
733
721
734
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
722
735
return
···
1045
1058
body = formatPatches[0].Body
1046
1059
}
1047
1060
1048
-
rkey := appview.TID()
1061
+
rkey := tid.TID()
1049
1062
initialSubmission := db.PullSubmission{
1050
1063
Patch: patch,
1051
1064
SourceRev: sourceRev,
1052
1065
}
1053
-
err = db.NewPull(tx, &db.Pull{
1066
+
pull := &db.Pull{
1054
1067
Title: title,
1055
1068
Body: body,
1056
1069
TargetBranch: targetBranch,
···
1061
1074
&initialSubmission,
1062
1075
},
1063
1076
PullSource: pullSource,
1064
-
})
1077
+
}
1078
+
err = db.NewPull(tx, pull)
1065
1079
if err != nil {
1066
1080
log.Println("failed to create pull request", err)
1067
1081
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
1101
1115
return
1102
1116
}
1103
1117
1104
-
if !s.config.Core.Dev {
1105
-
err = s.posthog.Enqueue(posthog.Capture{
1106
-
DistinctId: user.Did,
1107
-
Event: "new_pull",
1108
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId},
1109
-
})
1110
-
if err != nil {
1111
-
log.Println("failed to enqueue posthog event:", err)
1112
-
}
1113
-
}
1118
+
s.notifier.NewPull(r.Context(), pull)
1114
1119
1115
1120
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1116
1121
}
···
1673
1678
}
1674
1679
1675
1680
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1676
-
return
1677
1681
}
1678
1682
1679
1683
func (s *Pulls) resubmitStackedPullHelper(
···
1917
1921
}
1918
1922
1919
1923
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1920
-
return
1921
1924
}
1922
1925
1923
1926
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
···
2041
2044
2042
2045
// auth filter: only owner or collaborators can close
2043
2046
roles := f.RolesInRepo(user)
2047
+
isOwner := roles.IsOwner()
2044
2048
isCollaborator := roles.IsCollaborator()
2045
2049
isPullAuthor := user.Did == pull.OwnerDid
2046
-
isCloseAllowed := isCollaborator || isPullAuthor
2050
+
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2047
2051
if !isCloseAllowed {
2048
2052
log.Println("failed to close pull")
2049
2053
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2087
2091
}
2088
2092
2089
2093
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2090
-
return
2091
2094
}
2092
2095
2093
2096
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···
2109
2112
2110
2113
// auth filter: only owner or collaborators can close
2111
2114
roles := f.RolesInRepo(user)
2115
+
isOwner := roles.IsOwner()
2112
2116
isCollaborator := roles.IsCollaborator()
2113
2117
isPullAuthor := user.Did == pull.OwnerDid
2114
-
isCloseAllowed := isCollaborator || isPullAuthor
2118
+
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2115
2119
if !isCloseAllowed {
2116
2120
log.Println("failed to close pull")
2117
2121
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2155
2159
}
2156
2160
2157
2161
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2158
-
return
2159
2162
}
2160
2163
2161
2164
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
···
2181
2184
2182
2185
title := fp.Title
2183
2186
body := fp.Body
2184
-
rkey := appview.TID()
2187
+
rkey := tid.TID()
2185
2188
2186
2189
initialSubmission := db.PullSubmission{
2187
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
+458
-137
appview/repo/repo.go
+458
-137
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
84
+
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
85
+
refParam := chi.URLParam(r, "ref")
86
+
f, err := rp.repoResolver.Resolve(r)
87
+
if err != nil {
88
+
log.Println("failed to get repo and knot", err)
89
+
return
90
+
}
91
+
92
+
var uri string
93
+
if rp.config.Core.Dev {
94
+
uri = "http"
95
+
} else {
96
+
uri = "https"
97
+
}
98
+
url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.RepoName, url.PathEscape(refParam))
99
+
100
+
http.Redirect(w, r, url, http.StatusFound)
101
+
}
102
+
80
103
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
81
104
f, err := rp.repoResolver.Resolve(r)
82
105
if err != nil {
···
179
202
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
180
203
RepoInfo: f.RepoInfo(user),
181
204
})
182
-
return
183
205
}
184
206
185
207
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
···
374
396
375
397
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
376
398
// so we can safely redirect to the "parent" (which is the same file).
377
-
if len(result.Files) == 0 && result.Parent == treePath {
399
+
unescapedTreePath, _ := url.PathUnescape(treePath)
400
+
if len(result.Files) == 0 && result.Parent == unescapedTreePath {
378
401
http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
379
402
return
380
403
}
···
389
412
}
390
413
}
391
414
392
-
baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
393
-
baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
415
+
sortFiles(result.Files)
394
416
395
417
rp.pages.RepoTree(w, pages.RepoTreeParams{
396
418
LoggedInUser: user,
397
419
BreadCrumbs: breadcrumbs,
398
-
BaseTreeLink: baseTreeLink,
399
-
BaseBlobLink: baseBlobLink,
420
+
TreePath: treePath,
400
421
RepoInfo: f.RepoInfo(user),
401
422
RepoTreeResponse: result,
402
423
})
403
-
return
404
424
}
405
425
406
426
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
···
458
478
ArtifactMap: artifactMap,
459
479
DanglingArtifacts: danglingArtifacts,
460
480
})
461
-
return
462
481
}
463
482
464
483
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
···
480
499
return
481
500
}
482
501
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
-
})
502
+
sortBranches(result.Branches)
499
503
500
504
user := rp.oauth.GetUser(r)
501
505
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
···
503
507
RepoInfo: f.RepoInfo(user),
504
508
RepoBranchesResponse: *result,
505
509
})
506
-
return
507
510
}
508
511
509
512
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
···
554
557
showRendered = r.URL.Query().Get("code") != "true"
555
558
}
556
559
560
+
var unsupported bool
561
+
var isImage bool
562
+
var isVideo bool
563
+
var contentSrc string
564
+
565
+
if result.IsBinary {
566
+
ext := strings.ToLower(filepath.Ext(result.Path))
567
+
switch ext {
568
+
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
569
+
isImage = true
570
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
571
+
isVideo = true
572
+
default:
573
+
unsupported = true
574
+
}
575
+
576
+
// fetch the actual binary content like in RepoBlobRaw
577
+
578
+
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
579
+
contentSrc = blobURL
580
+
if !rp.config.Core.Dev {
581
+
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
582
+
}
583
+
}
584
+
557
585
user := rp.oauth.GetUser(r)
558
586
rp.pages.RepoBlob(w, pages.RepoBlobParams{
559
587
LoggedInUser: user,
···
562
590
BreadCrumbs: breadcrumbs,
563
591
ShowRendered: showRendered,
564
592
RenderToggle: renderToggle,
593
+
Unsupported: unsupported,
594
+
IsImage: isImage,
595
+
IsVideo: isVideo,
596
+
ContentSrc: contentSrc,
565
597
})
566
-
return
567
598
}
568
599
569
600
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
570
601
f, err := rp.repoResolver.Resolve(r)
571
602
if err != nil {
572
603
log.Println("failed to get repo and knot", err)
604
+
w.WriteHeader(http.StatusBadRequest)
573
605
return
574
606
}
575
607
···
580
612
if !rp.config.Core.Dev {
581
613
protocol = "https"
582
614
}
583
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
615
+
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
616
+
resp, err := http.Get(blobURL)
584
617
if err != nil {
585
-
log.Println("failed to reach knotserver", err)
618
+
log.Println("failed to reach knotserver:", err)
619
+
rp.pages.Error503(w)
586
620
return
587
621
}
622
+
defer resp.Body.Close()
588
623
589
-
body, err := io.ReadAll(resp.Body)
590
-
if err != nil {
591
-
log.Printf("Error reading response body: %v", err)
624
+
if resp.StatusCode != http.StatusOK {
625
+
log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
626
+
w.WriteHeader(resp.StatusCode)
627
+
_, _ = io.Copy(w, resp.Body)
592
628
return
593
629
}
594
630
595
-
var result types.RepoBlobResponse
596
-
err = json.Unmarshal(body, &result)
631
+
contentType := resp.Header.Get("Content-Type")
632
+
body, err := io.ReadAll(resp.Body)
597
633
if err != nil {
598
-
log.Println("failed to parse response:", err)
634
+
log.Printf("error reading response body from knotserver: %v", err)
635
+
w.WriteHeader(http.StatusInternalServerError)
599
636
return
600
637
}
601
638
602
-
if result.IsBinary {
603
-
w.Header().Set("Content-Type", "application/octet-stream")
639
+
if strings.Contains(contentType, "text/plain") {
640
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
641
+
w.Write(body)
642
+
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
643
+
w.Header().Set("Content-Type", contentType)
604
644
w.Write(body)
645
+
} else {
646
+
w.WriteHeader(http.StatusUnsupportedMediaType)
647
+
w.Write([]byte("unsupported content type"))
605
648
return
606
649
}
607
-
608
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
609
-
w.Write([]byte(result.Contents))
610
-
return
611
650
}
612
651
613
652
// modify the spindle configured for this repo
614
653
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
654
+
user := rp.oauth.GetUser(r)
655
+
l := rp.logger.With("handler", "EditSpindle")
656
+
l = l.With("did", user.Did)
657
+
l = l.With("handle", user.Handle)
658
+
659
+
errorId := "operation-error"
660
+
fail := func(msg string, err error) {
661
+
l.Error(msg, "err", err)
662
+
rp.pages.Notice(w, errorId, msg)
663
+
}
664
+
615
665
f, err := rp.repoResolver.Resolve(r)
616
666
if err != nil {
617
-
log.Println("failed to get repo and knot", err)
618
-
w.WriteHeader(http.StatusBadRequest)
667
+
fail("Failed to resolve repo. Try again later", err)
619
668
return
620
669
}
621
670
622
671
repoAt := f.RepoAt
623
672
rkey := repoAt.RecordKey().String()
624
673
if rkey == "" {
625
-
log.Println("invalid aturi for repo", err)
626
-
w.WriteHeader(http.StatusInternalServerError)
674
+
fail("Failed to resolve repo. Try again later", err)
627
675
return
628
676
}
629
-
630
-
user := rp.oauth.GetUser(r)
631
677
632
678
newSpindle := r.FormValue("spindle")
679
+
removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
633
680
client, err := rp.oauth.AuthorizedClient(r)
634
681
if err != nil {
635
-
log.Println("failed to get client")
636
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
682
+
fail("Failed to authorize. Try again later.", err)
637
683
return
638
684
}
639
685
640
-
// ensure that this is a valid spindle for this user
641
-
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
642
-
if err != nil {
643
-
log.Println("failed to get valid spindles")
644
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
645
-
return
686
+
if !removingSpindle {
687
+
// ensure that this is a valid spindle for this user
688
+
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
689
+
if err != nil {
690
+
fail("Failed to find spindles. Try again later.", err)
691
+
return
692
+
}
693
+
694
+
if !slices.Contains(validSpindles, newSpindle) {
695
+
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
696
+
return
697
+
}
646
698
}
647
699
648
-
if !slices.Contains(validSpindles, newSpindle) {
649
-
log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles)
650
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
651
-
return
700
+
spindlePtr := &newSpindle
701
+
if removingSpindle {
702
+
spindlePtr = nil
652
703
}
653
704
654
705
// optimistic update
655
-
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
706
+
err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
656
707
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.")
708
+
fail("Failed to update spindle. Try again later.", err)
659
709
return
660
710
}
661
711
662
712
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
663
713
if err != nil {
664
-
// failed to get record
665
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.")
714
+
fail("Failed to update spindle, no record found on PDS.", err)
666
715
return
667
716
}
668
717
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
···
677
726
Owner: user.Did,
678
727
CreatedAt: f.CreatedAt,
679
728
Description: &f.Description,
680
-
Spindle: &newSpindle,
729
+
Spindle: spindlePtr,
681
730
},
682
731
},
683
732
})
684
733
685
734
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.")
735
+
fail("Failed to update spindle, unable to save to PDS.", err)
689
736
return
690
737
}
691
738
692
-
// add this spindle to spindle stream
693
-
rp.spindlestream.AddSource(
694
-
context.Background(),
695
-
eventconsumer.NewSpindleSource(newSpindle),
696
-
)
739
+
if !removingSpindle {
740
+
// add this spindle to spindle stream
741
+
rp.spindlestream.AddSource(
742
+
context.Background(),
743
+
eventconsumer.NewSpindleSource(newSpindle),
744
+
)
745
+
}
697
746
698
-
w.Write(fmt.Append(nil, "spindle set to: ", newSpindle))
747
+
rp.pages.HxRefresh(w)
699
748
}
700
749
701
750
func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
751
+
user := rp.oauth.GetUser(r)
752
+
l := rp.logger.With("handler", "AddCollaborator")
753
+
l = l.With("did", user.Did)
754
+
l = l.With("handle", user.Handle)
755
+
702
756
f, err := rp.repoResolver.Resolve(r)
703
757
if err != nil {
704
-
log.Println("failed to get repo and knot", err)
758
+
l.Error("failed to get repo and knot", "err", err)
705
759
return
760
+
}
761
+
762
+
errorId := "add-collaborator-error"
763
+
fail := func(msg string, err error) {
764
+
l.Error(msg, "err", err)
765
+
rp.pages.Notice(w, errorId, msg)
706
766
}
707
767
708
768
collaborator := r.FormValue("collaborator")
709
769
if collaborator == "" {
710
-
http.Error(w, "malformed form", http.StatusBadRequest)
770
+
fail("Invalid form.", nil)
711
771
return
712
772
}
773
+
774
+
// remove a single leading `@`, to make @handle work with ResolveIdent
775
+
collaborator = strings.TrimPrefix(collaborator, "@")
713
776
714
777
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
715
778
if err != nil {
716
-
w.Write([]byte("failed to resolve collaborator did to a handle"))
779
+
fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
780
+
return
781
+
}
782
+
783
+
if collaboratorIdent.DID.String() == user.Did {
784
+
fail("You seem to be adding yourself as a collaborator.", nil)
717
785
return
718
786
}
719
-
log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
787
+
l = l.With("collaborator", collaboratorIdent.Handle)
788
+
l = l.With("knot", f.Knot)
720
789
721
-
// TODO: create an atproto record for this
790
+
// announce this relation into the firehose, store into owners' pds
791
+
client, err := rp.oauth.AuthorizedClient(r)
792
+
if err != nil {
793
+
fail("Failed to write to PDS.", err)
794
+
return
795
+
}
722
796
797
+
// emit a record
798
+
currentUser := rp.oauth.GetUser(r)
799
+
rkey := tid.TID()
800
+
createdAt := time.Now()
801
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
802
+
Collection: tangled.RepoCollaboratorNSID,
803
+
Repo: currentUser.Did,
804
+
Rkey: rkey,
805
+
Record: &lexutil.LexiconTypeDecoder{
806
+
Val: &tangled.RepoCollaborator{
807
+
Subject: collaboratorIdent.DID.String(),
808
+
Repo: string(f.RepoAt),
809
+
CreatedAt: createdAt.Format(time.RFC3339),
810
+
}},
811
+
})
812
+
// invalid record
813
+
if err != nil {
814
+
fail("Failed to write record to PDS.", err)
815
+
return
816
+
}
817
+
l = l.With("at-uri", resp.Uri)
818
+
l.Info("wrote record to PDS")
819
+
820
+
l.Info("adding to knot")
723
821
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
724
822
if err != nil {
725
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
823
+
fail("Failed to add to knot.", err)
726
824
return
727
825
}
728
826
729
827
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
730
828
if err != nil {
731
-
log.Println("failed to create client to ", f.Knot)
829
+
fail("Failed to add to knot.", err)
732
830
return
733
831
}
734
832
735
833
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
736
834
if err != nil {
737
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
835
+
fail("Knot was unreachable.", err)
738
836
return
739
837
}
740
838
741
839
if ksResp.StatusCode != http.StatusNoContent {
742
-
w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err))
840
+
fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil)
743
841
return
744
842
}
745
843
746
844
tx, err := rp.db.BeginTx(r.Context(), nil)
747
845
if err != nil {
748
-
log.Println("failed to start tx")
749
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
846
+
fail("Failed to add collaborator.", err)
750
847
return
751
848
}
752
849
defer func() {
753
850
tx.Rollback()
754
851
err = rp.enforcer.E.LoadPolicy()
755
852
if err != nil {
756
-
log.Println("failed to rollback policies")
853
+
fail("Failed to add collaborator.", err)
757
854
}
758
855
}()
759
856
760
857
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
761
858
if err != nil {
762
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
859
+
fail("Failed to add collaborator permissions.", err)
763
860
return
764
861
}
765
862
766
-
err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
863
+
err = db.AddCollaborator(rp.db, db.Collaborator{
864
+
Did: syntax.DID(currentUser.Did),
865
+
Rkey: rkey,
866
+
SubjectDid: collaboratorIdent.DID,
867
+
RepoAt: f.RepoAt,
868
+
Created: createdAt,
869
+
})
767
870
if err != nil {
768
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
871
+
fail("Failed to add collaborator.", err)
769
872
return
770
873
}
771
874
772
875
err = tx.Commit()
773
876
if err != nil {
774
-
log.Println("failed to commit changes", err)
775
-
http.Error(w, err.Error(), http.StatusInternalServerError)
877
+
fail("Failed to add collaborator.", err)
776
878
return
777
879
}
778
880
779
881
err = rp.enforcer.E.SavePolicy()
780
882
if err != nil {
781
-
log.Println("failed to update ACLs", err)
782
-
http.Error(w, err.Error(), http.StatusInternalServerError)
883
+
fail("Failed to update collaborator permissions.", err)
783
884
return
784
885
}
785
886
786
-
w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String()))
787
-
887
+
rp.pages.HxRefresh(w)
788
888
}
789
889
790
890
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
···
936
1036
w.Write(fmt.Append(nil, "default branch set to: ", branch))
937
1037
}
938
1038
939
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1039
+
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1040
+
user := rp.oauth.GetUser(r)
1041
+
l := rp.logger.With("handler", "Secrets")
1042
+
l = l.With("handle", user.Handle)
1043
+
l = l.With("did", user.Did)
1044
+
940
1045
f, err := rp.repoResolver.Resolve(r)
941
1046
if err != nil {
942
1047
log.Println("failed to get repo and knot", err)
943
1048
return
944
1049
}
945
1050
1051
+
if f.Spindle == "" {
1052
+
log.Println("empty spindle cannot add/rm secret", err)
1053
+
return
1054
+
}
1055
+
1056
+
lxm := tangled.RepoAddSecretNSID
1057
+
if r.Method == http.MethodDelete {
1058
+
lxm = tangled.RepoRemoveSecretNSID
1059
+
}
1060
+
1061
+
spindleClient, err := rp.oauth.ServiceClient(
1062
+
r,
1063
+
oauth.WithService(f.Spindle),
1064
+
oauth.WithLxm(lxm),
1065
+
oauth.WithDev(rp.config.Core.Dev),
1066
+
)
1067
+
if err != nil {
1068
+
log.Println("failed to create spindle client", err)
1069
+
return
1070
+
}
1071
+
1072
+
key := r.FormValue("key")
1073
+
if key == "" {
1074
+
w.WriteHeader(http.StatusBadRequest)
1075
+
return
1076
+
}
1077
+
946
1078
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
-
}
1079
+
case http.MethodPut:
1080
+
errorId := "add-secret-error"
954
1081
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
-
}
1082
+
value := r.FormValue("value")
1083
+
if value == "" {
1084
+
w.WriteHeader(http.StatusBadRequest)
1085
+
return
961
1086
}
962
1087
963
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1088
+
err = tangled.RepoAddSecret(
1089
+
r.Context(),
1090
+
spindleClient,
1091
+
&tangled.RepoAddSecret_Input{
1092
+
Repo: f.RepoAt.String(),
1093
+
Key: key,
1094
+
Value: value,
1095
+
},
1096
+
)
964
1097
if err != nil {
965
-
log.Println("failed to create unsigned client", err)
1098
+
l.Error("Failed to add secret.", "err", err)
1099
+
rp.pages.Notice(w, errorId, "Failed to add secret.")
966
1100
return
967
1101
}
968
1102
969
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1103
+
case http.MethodDelete:
1104
+
errorId := "operation-error"
1105
+
1106
+
err = tangled.RepoRemoveSecret(
1107
+
r.Context(),
1108
+
spindleClient,
1109
+
&tangled.RepoRemoveSecret_Input{
1110
+
Repo: f.RepoAt.String(),
1111
+
Key: key,
1112
+
},
1113
+
)
970
1114
if err != nil {
971
-
log.Println("failed to reach knotserver", err)
1115
+
l.Error("Failed to delete secret.", "err", err)
1116
+
rp.pages.Notice(w, errorId, "Failed to delete secret.")
972
1117
return
973
1118
}
1119
+
}
974
1120
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
1121
+
rp.pages.HxRefresh(w)
1122
+
}
1123
+
1124
+
type tab = map[string]any
1125
+
1126
+
var (
1127
+
// would be great to have ordered maps right about now
1128
+
settingsTabs []tab = []tab{
1129
+
{"Name": "general", "Icon": "sliders-horizontal"},
1130
+
{"Name": "access", "Icon": "users"},
1131
+
{"Name": "pipelines", "Icon": "layers-2"},
1132
+
}
1133
+
)
1134
+
1135
+
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1136
+
tabVal := r.URL.Query().Get("tab")
1137
+
if tabVal == "" {
1138
+
tabVal = "general"
1139
+
}
1140
+
1141
+
switch tabVal {
1142
+
case "general":
1143
+
rp.generalSettings(w, r)
1144
+
1145
+
case "access":
1146
+
rp.accessSettings(w, r)
1147
+
1148
+
case "pipelines":
1149
+
rp.pipelineSettings(w, r)
1150
+
}
1151
+
1152
+
// user := rp.oauth.GetUser(r)
1153
+
// repoCollaborators, err := f.Collaborators(r.Context())
1154
+
// if err != nil {
1155
+
// log.Println("failed to get collaborators", err)
1156
+
// }
1157
+
1158
+
// isCollaboratorInviteAllowed := false
1159
+
// if user != nil {
1160
+
// ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
1161
+
// if err == nil && ok {
1162
+
// isCollaboratorInviteAllowed = true
1163
+
// }
1164
+
// }
1165
+
1166
+
// us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1167
+
// if err != nil {
1168
+
// log.Println("failed to create unsigned client", err)
1169
+
// return
1170
+
// }
1171
+
1172
+
// result, err := us.Branches(f.OwnerDid(), f.RepoName)
1173
+
// if err != nil {
1174
+
// log.Println("failed to reach knotserver", err)
1175
+
// return
1176
+
// }
1177
+
1178
+
// // all spindles that this user is a member of
1179
+
// spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1180
+
// if err != nil {
1181
+
// log.Println("failed to fetch spindles", err)
1182
+
// return
1183
+
// }
1184
+
1185
+
// var secrets []*tangled.RepoListSecrets_Secret
1186
+
// if f.Spindle != "" {
1187
+
// if spindleClient, err := rp.oauth.ServiceClient(
1188
+
// r,
1189
+
// oauth.WithService(f.Spindle),
1190
+
// oauth.WithLxm(tangled.RepoListSecretsNSID),
1191
+
// oauth.WithDev(rp.config.Core.Dev),
1192
+
// ); err != nil {
1193
+
// log.Println("failed to create spindle client", err)
1194
+
// } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil {
1195
+
// log.Println("failed to fetch secrets", err)
1196
+
// } else {
1197
+
// secrets = resp.Secrets
1198
+
// }
1199
+
// }
1200
+
1201
+
// rp.pages.RepoSettings(w, pages.RepoSettingsParams{
1202
+
// LoggedInUser: user,
1203
+
// RepoInfo: f.RepoInfo(user),
1204
+
// Collaborators: repoCollaborators,
1205
+
// IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1206
+
// Branches: result.Branches,
1207
+
// Spindles: spindles,
1208
+
// CurrentSpindle: f.Spindle,
1209
+
// Secrets: secrets,
1210
+
// })
1211
+
}
1212
+
1213
+
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1214
+
f, err := rp.repoResolver.Resolve(r)
1215
+
user := rp.oauth.GetUser(r)
1216
+
1217
+
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1218
+
if err != nil {
1219
+
log.Println("failed to create unsigned client", err)
1220
+
return
1221
+
}
1222
+
1223
+
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1224
+
if err != nil {
1225
+
log.Println("failed to reach knotserver", err)
1226
+
return
1227
+
}
1228
+
1229
+
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1230
+
LoggedInUser: user,
1231
+
RepoInfo: f.RepoInfo(user),
1232
+
Branches: result.Branches,
1233
+
Tabs: settingsTabs,
1234
+
Tab: "general",
1235
+
})
1236
+
}
1237
+
1238
+
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
1239
+
f, err := rp.repoResolver.Resolve(r)
1240
+
user := rp.oauth.GetUser(r)
1241
+
1242
+
repoCollaborators, err := f.Collaborators(r.Context())
1243
+
if err != nil {
1244
+
log.Println("failed to get collaborators", err)
1245
+
}
1246
+
1247
+
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
1248
+
LoggedInUser: user,
1249
+
RepoInfo: f.RepoInfo(user),
1250
+
Tabs: settingsTabs,
1251
+
Tab: "access",
1252
+
Collaborators: repoCollaborators,
1253
+
})
1254
+
}
1255
+
1256
+
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
1257
+
f, err := rp.repoResolver.Resolve(r)
1258
+
user := rp.oauth.GetUser(r)
1259
+
1260
+
// all spindles that the repo owner is a member of
1261
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1262
+
if err != nil {
1263
+
log.Println("failed to fetch spindles", err)
1264
+
return
1265
+
}
1266
+
1267
+
var secrets []*tangled.RepoListSecrets_Secret
1268
+
if f.Spindle != "" {
1269
+
if spindleClient, err := rp.oauth.ServiceClient(
1270
+
r,
1271
+
oauth.WithService(f.Spindle),
1272
+
oauth.WithLxm(tangled.RepoListSecretsNSID),
1273
+
oauth.WithDev(rp.config.Core.Dev),
1274
+
); err != nil {
1275
+
log.Println("failed to create spindle client", err)
1276
+
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil {
1277
+
log.Println("failed to fetch secrets", err)
1278
+
} else {
1279
+
secrets = resp.Secrets
980
1280
}
1281
+
}
981
1282
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,
1283
+
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
1284
+
return strings.Compare(a.Key, b.Key)
1285
+
})
1286
+
1287
+
var dids []string
1288
+
for _, s := range secrets {
1289
+
dids = append(dids, s.CreatedBy)
1290
+
}
1291
+
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
1292
+
1293
+
// convert to a more manageable form
1294
+
var niceSecret []map[string]any
1295
+
for id, s := range secrets {
1296
+
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
1297
+
niceSecret = append(niceSecret, map[string]any{
1298
+
"Id": id,
1299
+
"Key": s.Key,
1300
+
"CreatedAt": when,
1301
+
"CreatedBy": resolvedIdents[id].Handle.String(),
990
1302
})
991
1303
}
1304
+
1305
+
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
1306
+
LoggedInUser: user,
1307
+
RepoInfo: f.RepoInfo(user),
1308
+
Tabs: settingsTabs,
1309
+
Tab: "pipelines",
1310
+
Spindles: spindles,
1311
+
CurrentSpindle: f.Spindle,
1312
+
Secrets: niceSecret,
1313
+
})
992
1314
}
993
1315
994
1316
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
···
1108
1430
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1109
1431
sourceAt := f.RepoAt.String()
1110
1432
1111
-
rkey := appview.TID()
1433
+
rkey := tid.TID()
1112
1434
repo := &db.Repo{
1113
1435
Did: user.Did,
1114
1436
Name: forkName,
···
1233
1555
return
1234
1556
}
1235
1557
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
-
})
1558
+
1559
+
sortBranches(branches)
1239
1560
1240
1561
var defaultBranch string
1241
1562
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{})
+6
appview/repo/router.go
+6
appview/repo/router.go
···
38
38
r.Get("/blob/{ref}/*", rp.RepoBlob)
39
39
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
40
40
41
+
// intentionally doesn't use /* as this isn't
42
+
// a file path
43
+
r.Get("/archive/{ref}", rp.DownloadArchive)
44
+
41
45
r.Route("/fork", func(r chi.Router) {
42
46
r.Use(middleware.AuthMiddleware(rp.oauth))
43
47
r.Get("/", rp.ForkRepo)
···
74
78
r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator)
75
79
r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo)
76
80
r.Put("/branches/default", rp.SetDefaultBranch)
81
+
r.Put("/secrets", rp.Secrets)
82
+
r.Delete("/secrets", rp.Secrets)
77
83
})
78
84
})
79
85
+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: fmt.Sprintf(`"did=%s"`, 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
+
}
+20
-12
appview/spindles/spindles.go
+20
-12
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{
···
610
619
611
620
if string(spindles[0].Owner) != user.Did {
612
621
l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
613
-
s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
622
+
s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.")
614
623
return
615
624
}
616
625
617
626
member := r.FormValue("member")
618
627
if member == "" {
619
628
l.Error("empty member")
620
-
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
629
+
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
621
630
return
622
631
}
623
632
l = l.With("member", member)
···
625
634
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
626
635
if err != nil {
627
636
l.Error("failed to resolve member identity to handle", "err", err)
628
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
637
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
629
638
return
630
639
}
631
640
if memberId.Handle.IsInvalidHandle() {
632
641
l.Error("failed to resolve member identity to handle")
633
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
642
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
634
643
return
635
644
}
636
645
···
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
}
+111
-27
appview/state/profile.go
+111
-27
appview/state/profile.go
···
1
1
package state
2
2
3
3
import (
4
-
"crypto/hmac"
5
-
"crypto/sha256"
6
-
"encoding/hex"
4
+
"context"
7
5
"fmt"
8
6
"log"
9
7
"net/http"
···
16
14
"github.com/bluesky-social/indigo/atproto/syntax"
17
15
lexutil "github.com/bluesky-social/indigo/lex/util"
18
16
"github.com/go-chi/chi/v5"
19
-
"github.com/posthog/posthog-go"
17
+
"github.com/gorilla/feeds"
20
18
"tangled.sh/tangled.sh/core/api/tangled"
21
19
"tangled.sh/tangled.sh/core/appview/db"
22
20
"tangled.sh/tangled.sh/core/appview/pages"
···
143
141
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
144
142
}
145
143
146
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
147
144
s.pages.ProfilePage(w, pages.ProfilePageParams{
148
145
LoggedInUser: loggedInUser,
149
146
Repos: pinnedRepos,
···
152
149
Card: pages.ProfileCard{
153
150
UserDid: ident.DID.String(),
154
151
UserHandle: ident.Handle.String(),
155
-
AvatarUri: profileAvatarUri,
156
152
Profile: profile,
157
153
FollowStatus: followStatus,
158
154
Followers: followers,
···
195
191
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
196
192
}
197
193
198
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
199
-
200
194
s.pages.ReposPage(w, pages.ReposPageParams{
201
195
LoggedInUser: loggedInUser,
202
196
Repos: repos,
···
204
198
Card: pages.ProfileCard{
205
199
UserDid: ident.DID.String(),
206
200
UserHandle: ident.Handle.String(),
207
-
AvatarUri: profileAvatarUri,
208
201
Profile: profile,
209
202
FollowStatus: followStatus,
210
203
Followers: followers,
···
213
206
})
214
207
}
215
208
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)
209
+
func (s *State) feedFromRequest(w http.ResponseWriter, r *http.Request) *feeds.Feed {
210
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
211
+
if !ok {
212
+
s.pages.Error404(w)
213
+
return nil
214
+
}
215
+
216
+
feed, err := s.GetProfileFeed(r.Context(), ident.Handle.String(), ident.DID.String())
217
+
if err != nil {
218
+
s.pages.Error500(w)
219
+
return nil
220
+
}
221
+
222
+
return feed
223
+
}
224
+
225
+
func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
226
+
feed := s.feedFromRequest(w, r)
227
+
if feed == nil {
228
+
return
229
+
}
230
+
231
+
atom, err := feed.ToAtom()
232
+
if err != nil {
233
+
s.pages.Error500(w)
234
+
return
235
+
}
236
+
237
+
w.Header().Set("content-type", "application/atom+xml")
238
+
w.Write([]byte(atom))
239
+
}
240
+
241
+
func (s *State) GetProfileFeed(ctx context.Context, handle string, did string) (*feeds.Feed, error) {
242
+
timeline, err := db.MakeProfileTimeline(s.db, did)
243
+
if err != nil {
244
+
return nil, err
245
+
}
246
+
247
+
author := &feeds.Author{
248
+
Name: fmt.Sprintf("@%s", handle),
249
+
}
250
+
feed := &feeds.Feed{
251
+
Title: fmt.Sprintf("timeline feed for %s", author.Name),
252
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, handle), Type: "text/html", Rel: "alternate"},
253
+
Items: make([]*feeds.Item, 0),
254
+
Updated: time.UnixMilli(0),
255
+
Author: author,
256
+
}
257
+
for _, byMonth := range timeline.ByMonth {
258
+
for _, pull := range byMonth.PullEvents.Items {
259
+
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
260
+
if err != nil {
261
+
return nil, err
262
+
}
263
+
feed.Items = append(feed.Items, &feeds.Item{
264
+
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
265
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
266
+
Created: pull.Created,
267
+
Author: author,
268
+
})
269
+
for _, submission := range pull.Submissions {
270
+
feed.Items = append(feed.Items, &feeds.Item{
271
+
Title: fmt.Sprintf("%s submitted pull request '%s' (round #%d) in @%s/%s", author.Name, pull.Title, submission.RoundNumber, owner.Handle, pull.Repo.Name),
272
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
273
+
Created: submission.Created,
274
+
Author: author,
275
+
})
276
+
}
277
+
}
278
+
for _, issue := range byMonth.IssueEvents.Items {
279
+
owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
280
+
if err != nil {
281
+
return nil, err
282
+
}
283
+
feed.Items = append(feed.Items, &feeds.Item{
284
+
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name),
285
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
286
+
Created: issue.Created,
287
+
Author: author,
288
+
})
289
+
}
290
+
for _, repo := range byMonth.RepoEvents {
291
+
var title string
292
+
if repo.Source != nil {
293
+
id, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
294
+
if err != nil {
295
+
return nil, err
296
+
}
297
+
title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, id.Handle, repo.Source.Name, repo.Repo.Name)
298
+
} else {
299
+
title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
300
+
}
301
+
feed.Items = append(feed.Items, &feeds.Item{
302
+
Title: title,
303
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, handle, repo.Repo.Name), Type: "text/html", Rel: "alternate"},
304
+
Created: repo.Repo.Created,
305
+
Author: author,
306
+
})
307
+
}
308
+
}
309
+
slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
310
+
return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
311
+
})
312
+
if len(feed.Items) > 0 {
313
+
feed.Updated = feed.Items[0].Created
314
+
}
315
+
316
+
return feed, nil
222
317
}
223
318
224
319
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
···
266
361
}
267
362
268
363
s.updateProfile(profile, w, r)
269
-
return
270
364
}
271
365
272
366
func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
···
306
400
profile.PinnedRepos = pinnedRepos
307
401
308
402
s.updateProfile(profile, w, r)
309
-
return
310
403
}
311
404
312
405
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
···
371
464
return
372
465
}
373
466
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
-
}
467
+
s.notifier.UpdateProfile(r.Context(), profile)
383
468
384
469
s.pages.HxRedirect(w, "/"+user.Did)
385
-
return
386
470
}
387
471
388
472
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
+36
-7
appview/state/router.go
+36
-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
···
66
68
func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
67
69
r := chi.NewRouter()
68
70
69
-
// strip @ from user
70
-
r.Use(middleware.StripLeadingAt)
71
-
72
71
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
73
72
r.Get("/", s.Profile)
73
+
r.Get("/feed.atom", s.AtomFeedPage)
74
74
75
75
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
76
76
r.Use(mw.GoImport())
···
135
135
})
136
136
137
137
r.Mount("/settings", s.SettingsRouter())
138
+
r.Mount("/strings", s.StringsRouter(mw))
138
139
r.Mount("/knots", s.KnotsRouter(mw))
139
140
r.Mount("/spindles", s.SpindlesRouter())
141
+
r.Mount("/signup", s.SignupRouter())
140
142
r.Mount("/", s.OAuthRouter())
141
143
142
144
r.Get("/keys/{user}", s.Keys)
145
+
r.Get("/terms", s.TermsOfService)
146
+
r.Get("/privacy", s.PrivacyPolicy)
143
147
144
148
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
145
149
s.pages.Error404(w)
···
197
201
return knots.Router(mw)
198
202
}
199
203
204
+
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
205
+
logger := log.New("strings")
206
+
207
+
strs := &avstrings.Strings{
208
+
Db: s.db,
209
+
OAuth: s.oauth,
210
+
Pages: s.pages,
211
+
Config: s.config,
212
+
Enforcer: s.enforcer,
213
+
IdResolver: s.idResolver,
214
+
Knotstream: s.knotstream,
215
+
Logger: logger,
216
+
}
217
+
218
+
return strs.Router(mw)
219
+
}
220
+
200
221
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)
222
+
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
202
223
return issues.Router(mw)
203
224
}
204
225
205
226
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)
227
+
pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
207
228
return pulls.Router(mw)
208
229
}
209
230
210
231
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)
232
+
logger := log.New("repo")
233
+
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger)
212
234
return repo.Router(mw)
213
235
}
214
236
215
237
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)
238
+
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
217
239
return pipes.Router(mw)
218
240
}
241
+
242
+
func (s *State) SignupRouter() http.Handler {
243
+
logger := log.New("signup")
244
+
245
+
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger)
246
+
return sig.Router()
247
+
}
+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
+
}
+454
appview/strings/strings.go
+454
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("string not found")
104
+
s.Pages.Error404(w)
105
+
return
106
+
}
107
+
if len(strings) != 1 {
108
+
l.Error("incorrect number of records returned", "len(strings)", len(strings))
109
+
w.WriteHeader(http.StatusInternalServerError)
110
+
return
111
+
}
112
+
string := strings[0]
113
+
114
+
if path.Base(r.URL.Path) == "raw" {
115
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
116
+
if string.Filename != "" {
117
+
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename))
118
+
}
119
+
w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents)))
120
+
121
+
_, err = w.Write([]byte(string.Contents))
122
+
if err != nil {
123
+
l.Error("failed to write raw response", "err", err)
124
+
}
125
+
return
126
+
}
127
+
128
+
var showRendered, renderToggle bool
129
+
if markup.GetFormat(string.Filename) == markup.FormatMarkdown {
130
+
renderToggle = true
131
+
showRendered = r.URL.Query().Get("code") != "true"
132
+
}
133
+
134
+
s.Pages.SingleString(w, pages.SingleStringParams{
135
+
LoggedInUser: s.OAuth.GetUser(r),
136
+
RenderToggle: renderToggle,
137
+
ShowRendered: showRendered,
138
+
String: string,
139
+
Stats: string.Stats(),
140
+
Owner: id,
141
+
})
142
+
}
143
+
144
+
func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
145
+
l := s.Logger.With("handler", "dashboard")
146
+
147
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
148
+
if !ok {
149
+
l.Error("malformed middleware")
150
+
w.WriteHeader(http.StatusInternalServerError)
151
+
return
152
+
}
153
+
l = l.With("did", id.DID, "handle", id.Handle)
154
+
155
+
all, err := db.GetStrings(
156
+
s.Db,
157
+
db.FilterEq("did", id.DID),
158
+
)
159
+
if err != nil {
160
+
l.Error("failed to fetch strings", "err", err)
161
+
w.WriteHeader(http.StatusInternalServerError)
162
+
return
163
+
}
164
+
165
+
slices.SortFunc(all, func(a, b db.String) int {
166
+
if a.Created.After(b.Created) {
167
+
return -1
168
+
} else {
169
+
return 1
170
+
}
171
+
})
172
+
173
+
profile, err := db.GetProfile(s.Db, id.DID.String())
174
+
if err != nil {
175
+
l.Error("failed to fetch user profile", "err", err)
176
+
w.WriteHeader(http.StatusInternalServerError)
177
+
return
178
+
}
179
+
loggedInUser := s.OAuth.GetUser(r)
180
+
followStatus := db.IsNotFollowing
181
+
if loggedInUser != nil {
182
+
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
183
+
}
184
+
185
+
followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String())
186
+
if err != nil {
187
+
l.Error("failed to get follow stats", "err", err)
188
+
}
189
+
190
+
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
191
+
LoggedInUser: s.OAuth.GetUser(r),
192
+
Card: pages.ProfileCard{
193
+
UserDid: id.DID.String(),
194
+
UserHandle: id.Handle.String(),
195
+
Profile: profile,
196
+
FollowStatus: followStatus,
197
+
Followers: followers,
198
+
Following: following,
199
+
},
200
+
Strings: all,
201
+
})
202
+
}
203
+
204
+
func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
205
+
l := s.Logger.With("handler", "edit")
206
+
207
+
user := s.OAuth.GetUser(r)
208
+
209
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
210
+
if !ok {
211
+
l.Error("malformed middleware")
212
+
w.WriteHeader(http.StatusInternalServerError)
213
+
return
214
+
}
215
+
l = l.With("did", id.DID, "handle", id.Handle)
216
+
217
+
rkey := chi.URLParam(r, "rkey")
218
+
if rkey == "" {
219
+
l.Error("malformed url, empty rkey")
220
+
w.WriteHeader(http.StatusBadRequest)
221
+
return
222
+
}
223
+
l = l.With("rkey", rkey)
224
+
225
+
// get the string currently being edited
226
+
all, err := db.GetStrings(
227
+
s.Db,
228
+
db.FilterEq("did", id.DID),
229
+
db.FilterEq("rkey", rkey),
230
+
)
231
+
if err != nil {
232
+
l.Error("failed to fetch string", "err", err)
233
+
w.WriteHeader(http.StatusInternalServerError)
234
+
return
235
+
}
236
+
if len(all) != 1 {
237
+
l.Error("incorrect number of records returned", "len(strings)", len(all))
238
+
w.WriteHeader(http.StatusInternalServerError)
239
+
return
240
+
}
241
+
first := all[0]
242
+
243
+
// verify that the logged in user owns this string
244
+
if user.Did != id.DID.String() {
245
+
l.Error("unauthorized request", "expected", id.DID, "got", user.Did)
246
+
w.WriteHeader(http.StatusUnauthorized)
247
+
return
248
+
}
249
+
250
+
switch r.Method {
251
+
case http.MethodGet:
252
+
// return the form with prefilled fields
253
+
s.Pages.PutString(w, pages.PutStringParams{
254
+
LoggedInUser: s.OAuth.GetUser(r),
255
+
Action: "edit",
256
+
String: first,
257
+
})
258
+
case http.MethodPost:
259
+
fail := func(msg string, err error) {
260
+
l.Error(msg, "err", err)
261
+
s.Pages.Notice(w, "error", msg)
262
+
}
263
+
264
+
filename := r.FormValue("filename")
265
+
if filename == "" {
266
+
fail("Empty filename.", nil)
267
+
return
268
+
}
269
+
if !strings.Contains(filename, ".") {
270
+
// TODO: make this a htmx form validation
271
+
fail("No extension provided for filename.", nil)
272
+
return
273
+
}
274
+
275
+
content := r.FormValue("content")
276
+
if content == "" {
277
+
fail("Empty contents.", nil)
278
+
return
279
+
}
280
+
281
+
description := r.FormValue("description")
282
+
283
+
// construct new string from form values
284
+
entry := db.String{
285
+
Did: first.Did,
286
+
Rkey: first.Rkey,
287
+
Filename: filename,
288
+
Description: description,
289
+
Contents: content,
290
+
Created: first.Created,
291
+
}
292
+
293
+
record := entry.AsRecord()
294
+
295
+
client, err := s.OAuth.AuthorizedClient(r)
296
+
if err != nil {
297
+
fail("Failed to create record.", err)
298
+
return
299
+
}
300
+
301
+
// first replace the existing record in the PDS
302
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
303
+
if err != nil {
304
+
fail("Failed to updated existing record.", err)
305
+
return
306
+
}
307
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
308
+
Collection: tangled.StringNSID,
309
+
Repo: entry.Did.String(),
310
+
Rkey: entry.Rkey,
311
+
SwapRecord: ex.Cid,
312
+
Record: &lexutil.LexiconTypeDecoder{
313
+
Val: &record,
314
+
},
315
+
})
316
+
if err != nil {
317
+
fail("Failed to updated existing record.", err)
318
+
return
319
+
}
320
+
l := l.With("aturi", resp.Uri)
321
+
l.Info("edited string")
322
+
323
+
// if that went okay, updated the db
324
+
if err = db.AddString(s.Db, entry); err != nil {
325
+
fail("Failed to update string.", err)
326
+
return
327
+
}
328
+
329
+
// if that went okay, redir to the string
330
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
331
+
}
332
+
333
+
}
334
+
335
+
func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
336
+
l := s.Logger.With("handler", "create")
337
+
user := s.OAuth.GetUser(r)
338
+
339
+
switch r.Method {
340
+
case http.MethodGet:
341
+
s.Pages.PutString(w, pages.PutStringParams{
342
+
LoggedInUser: s.OAuth.GetUser(r),
343
+
Action: "new",
344
+
})
345
+
case http.MethodPost:
346
+
fail := func(msg string, err error) {
347
+
l.Error(msg, "err", err)
348
+
s.Pages.Notice(w, "error", msg)
349
+
}
350
+
351
+
filename := r.FormValue("filename")
352
+
if filename == "" {
353
+
fail("Empty filename.", nil)
354
+
return
355
+
}
356
+
if !strings.Contains(filename, ".") {
357
+
// TODO: make this a htmx form validation
358
+
fail("No extension provided for filename.", nil)
359
+
return
360
+
}
361
+
362
+
content := r.FormValue("content")
363
+
if content == "" {
364
+
fail("Empty contents.", nil)
365
+
return
366
+
}
367
+
368
+
description := r.FormValue("description")
369
+
370
+
string := db.String{
371
+
Did: syntax.DID(user.Did),
372
+
Rkey: tid.TID(),
373
+
Filename: filename,
374
+
Description: description,
375
+
Contents: content,
376
+
Created: time.Now(),
377
+
}
378
+
379
+
record := string.AsRecord()
380
+
381
+
client, err := s.OAuth.AuthorizedClient(r)
382
+
if err != nil {
383
+
fail("Failed to create record.", err)
384
+
return
385
+
}
386
+
387
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
388
+
Collection: tangled.StringNSID,
389
+
Repo: user.Did,
390
+
Rkey: string.Rkey,
391
+
Record: &lexutil.LexiconTypeDecoder{
392
+
Val: &record,
393
+
},
394
+
})
395
+
if err != nil {
396
+
fail("Failed to create record.", err)
397
+
return
398
+
}
399
+
l := l.With("aturi", resp.Uri)
400
+
l.Info("created record")
401
+
402
+
// insert into DB
403
+
if err = db.AddString(s.Db, string); err != nil {
404
+
fail("Failed to create string.", err)
405
+
return
406
+
}
407
+
408
+
// successful
409
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
410
+
}
411
+
}
412
+
413
+
func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
414
+
l := s.Logger.With("handler", "create")
415
+
user := s.OAuth.GetUser(r)
416
+
fail := func(msg string, err error) {
417
+
l.Error(msg, "err", err)
418
+
s.Pages.Notice(w, "error", msg)
419
+
}
420
+
421
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
422
+
if !ok {
423
+
l.Error("malformed middleware")
424
+
w.WriteHeader(http.StatusInternalServerError)
425
+
return
426
+
}
427
+
l = l.With("did", id.DID, "handle", id.Handle)
428
+
429
+
rkey := chi.URLParam(r, "rkey")
430
+
if rkey == "" {
431
+
l.Error("malformed url, empty rkey")
432
+
w.WriteHeader(http.StatusBadRequest)
433
+
return
434
+
}
435
+
436
+
if user.Did != id.DID.String() {
437
+
fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
438
+
return
439
+
}
440
+
441
+
if err := db.DeleteString(
442
+
s.Db,
443
+
db.FilterEq("did", user.Did),
444
+
db.FilterEq("rkey", rkey),
445
+
); err != nil {
446
+
fail("Failed to delete string.", err)
447
+
return
448
+
}
449
+
450
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
451
+
}
452
+
453
+
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
454
+
}
-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
}
+8
-15
docs/contributing.md
+8
-15
docs/contributing.md
···
115
115
If you're submitting a PR with multiple commits, make sure each one is
116
116
signed.
117
117
118
-
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can add this to
119
-
your jj config:
118
+
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
119
+
to make it sign off commits in the tangled repo:
120
120
121
-
```
122
-
ui.should-sign-off = true
123
-
```
124
-
125
-
and to your `templates.draft_commit_description`, add the following `if`
126
-
block:
127
-
128
-
```
129
-
if(
130
-
config("ui.should-sign-off").as_boolean() && !description.contains("Signed-off-by: " ++ author.name()),
131
-
"\nSigned-off-by: " ++ author.name() ++ " <" ++ author.email() ++ ">",
132
-
),
121
+
```shell
122
+
# Safety check, should say "No matching config key..."
123
+
jj config list templates.commit_trailers
124
+
# The command below may need to be adjusted if the command above returned something.
125
+
jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
133
126
```
134
127
135
128
Refer to the [jj
136
-
documentation](https://jj-vcs.github.io/jj/latest/config/#default-description)
129
+
documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
137
130
for more information.
+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 create a knot with hostname `localhost:6000`. This will
60
+
generate a knot secret. Set `$TANGLED_VM_KNOT_SECRET` to it,
61
+
ideally in a `.envrc` with [direnv](https://direnv.net) so you
62
+
don't lose it.
45
63
46
64
You can now start a lightweight NixOS VM using
47
65
`nixos-shell` like so:
···
71
89
git remote add local-dev git@nixos-shell:user/repo
72
90
git push local-dev main
73
91
```
92
+
93
+
## running a spindle
94
+
95
+
Be sure to set `$TANGLED_VM_SPINDLE_OWNER` to your own DID.
96
+
The above VM should already be running a spindle on `localhost:6555`.
97
+
You can head to the spindle dashboard on `http://localhost:3000/spindles`,
98
+
and register a spindle with hostname `localhost:6555`. It should instantly
99
+
be verified. You can then configure each repository to use this spindle
100
+
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
+
```
+23
-5
docs/knot-hosting.md
+23
-5
docs/knot-hosting.md
···
2
2
3
3
So you want to run your own knot server? Great! Here are a few prerequisites:
4
4
5
-
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind.
5
+
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
6
6
2. A (sub)domain name. People generally use `knot.example.com`.
7
7
3. A valid SSL certificate for your domain.
8
8
···
59
59
EOF
60
60
```
61
61
62
+
Then, reload `sshd`:
63
+
64
+
```
65
+
sudo systemctl reload ssh
66
+
```
67
+
62
68
Next, create the `git` user. We'll use the `git` user's home directory
63
69
to store repositories:
64
70
···
67
73
```
68
74
69
75
Create `/home/git/.knot.env` with the following, updating the values as
70
-
necessary. The `KNOT_SERVER_SECRET` can be obtaind from the
71
-
[/knots](/knots) page on Tangled.
76
+
necessary. The `KNOT_SERVER_SECRET` can be obtained from the
77
+
[/knots](https://tangled.sh/knots) page on Tangled.
72
78
73
79
```
74
80
KNOT_REPO_SCAN_PATH=/home/git
···
89
95
systemctl start knotserver
90
96
```
91
97
92
-
The last step is to configure a reverse proxy like Nginx or Caddy to front yourself
98
+
The last step is to configure a reverse proxy like Nginx or Caddy to front your
93
99
knot. Here's an example configuration for Nginx:
94
100
95
101
```
···
123
129
knot domain.
124
130
125
131
You should now have a running knot server! You can finalize your registration by hitting the
126
-
`initialize` button on the [/knots](/knots) page.
132
+
`initialize` button on the [/knots](https://tangled.sh/knots) page.
127
133
128
134
### custom paths
129
135
···
191
197
```
192
198
193
199
Make sure to restart your SSH server!
200
+
201
+
#### MOTD (message of the day)
202
+
203
+
To configure the MOTD used ("Welcome to this knot!" by default), edit the
204
+
`/home/git/motd` file:
205
+
206
+
```
207
+
printf "Hi from this knot!\n" > /home/git/motd
208
+
```
209
+
210
+
Note that you should add a newline at the end if setting a non-empty message
211
+
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 -f -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.
+10
-31
flake.lock
+10
-31
flake.lock
···
1
1
{
2
2
"nodes": {
3
-
"gitignore": {
4
-
"inputs": {
5
-
"nixpkgs": [
6
-
"nixpkgs"
7
-
]
8
-
},
9
-
"locked": {
10
-
"lastModified": 1709087332,
11
-
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
12
-
"owner": "hercules-ci",
13
-
"repo": "gitignore.nix",
14
-
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
15
-
"type": "github"
16
-
},
17
-
"original": {
18
-
"owner": "hercules-ci",
19
-
"repo": "gitignore.nix",
20
-
"type": "github"
21
-
}
22
-
},
23
3
"flake-utils": {
24
4
"inputs": {
25
5
"systems": "systems"
···
46
26
]
47
27
},
48
28
"locked": {
49
-
"lastModified": 1751702058,
50
-
"narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=",
29
+
"lastModified": 1754078208,
30
+
"narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=",
51
31
"owner": "nix-community",
52
32
"repo": "gomod2nix",
53
-
"rev": "664ad7a2df4623037e315e4094346bff5c44e9ee",
33
+
"rev": "7f963246a71626c7fc70b431a315c4388a0c95cf",
54
34
"type": "github"
55
35
},
56
36
"original": {
···
99
79
"indigo": {
100
80
"flake": false,
101
81
"locked": {
102
-
"lastModified": 1745333930,
103
-
"narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=",
82
+
"lastModified": 1753693716,
83
+
"narHash": "sha256-DMIKnCJRODQXEHUxA+7mLzRALmnZhkkbHlFT2rCQYrE=",
104
84
"owner": "oppiliappan",
105
85
"repo": "indigo",
106
-
"rev": "e4e59280737b8676611fc077a228d47b3e8e9491",
86
+
"rev": "5f170569da9360f57add450a278d73538092d8ca",
107
87
"type": "github"
108
88
},
109
89
"original": {
···
128
108
"lucide-src": {
129
109
"flake": false,
130
110
"locked": {
131
-
"lastModified": 1742302029,
132
-
"narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=",
111
+
"lastModified": 1754044466,
112
+
"narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=",
133
113
"type": "tarball",
134
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
114
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
135
115
},
136
116
"original": {
137
117
"type": "tarball",
138
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
118
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
139
119
}
140
120
},
141
121
"nixpkgs": {
···
156
136
},
157
137
"root": {
158
138
"inputs": {
159
-
"gitignore": "gitignore",
160
139
"gomod2nix": "gomod2nix",
161
140
"htmx-src": "htmx-src",
162
141
"htmx-ws-src": "htmx-ws-src",
+101
-29
flake.nix
+101
-29
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 = {
···
37
37
url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip";
38
38
flake = false;
39
39
};
40
-
gitignore = {
41
-
url = "github:hercules-ci/gitignore.nix";
42
-
inputs.nixpkgs.follows = "nixpkgs";
43
-
};
44
40
};
45
41
46
42
outputs = {
···
51
47
htmx-src,
52
48
htmx-ws-src,
53
49
lucide-src,
54
-
gitignore,
55
50
inter-fonts-src,
56
51
sqlite-lib-src,
57
52
ibm-plex-mono-src,
···
62
57
63
58
mkPackageSet = pkgs:
64
59
pkgs.lib.makeScope pkgs.newScope (self: {
65
-
inherit (gitignore.lib) gitignoreSource;
60
+
src = let
61
+
fs = pkgs.lib.fileset;
62
+
in
63
+
fs.toSource {
64
+
root = ./.;
65
+
fileset = fs.difference (fs.intersection (fs.gitTracked ./.) (fs.fileFilter (file: !(file.hasExt "nix")) ./.)) (fs.maybeMissing ./.jj);
66
+
};
66
67
buildGoApplication =
67
68
(self.callPackage "${gomod2nix}/builder" {
68
69
gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix;
···
74
75
};
75
76
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
76
77
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
77
-
appview = self.callPackage ./nix/pkgs/appview.nix {
78
+
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
78
79
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
79
80
};
81
+
appview = self.callPackage ./nix/pkgs/appview.nix {};
80
82
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
81
83
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
82
84
knot = self.callPackage ./nix/pkgs/knot.nix {};
···
92
94
staticPackages = mkPackageSet pkgs.pkgsStatic;
93
95
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
94
96
in {
95
-
appview = packages.appview;
96
-
lexgen = packages.lexgen;
97
-
knot = packages.knot;
98
-
knot-unwrapped = packages.knot-unwrapped;
99
-
spindle = packages.spindle;
100
-
genjwks = packages.genjwks;
101
-
sqlite-lib = packages.sqlite-lib;
97
+
inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib;
102
98
103
99
pkgsStatic-appview = staticPackages.appview;
104
100
pkgsStatic-knot = staticPackages.knot;
···
131
127
pkgs.tailwindcss
132
128
pkgs.nixos-shell
133
129
pkgs.redis
130
+
pkgs.coreutils # for those of us who are on systems that use busybox (alpine)
134
131
packages'.lexgen
135
132
];
136
133
shellHook = ''
137
-
mkdir -p appview/pages/static/{fonts,icons}
138
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
139
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
140
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
141
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
142
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
143
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
134
+
mkdir -p appview/pages/static
135
+
# no preserve is needed because watch-tailwind will want to be able to overwrite
136
+
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
144
137
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
145
138
'';
146
139
env.CGO_ENABLED = 1;
···
148
141
});
149
142
apps = forAllSystems (system: let
150
143
pkgs = nixpkgsFor."${system}";
144
+
packages' = self.packages.${system};
151
145
air-watcher = name: arg:
152
146
pkgs.writeShellScriptBin "run"
153
147
''
···
166
160
in {
167
161
watch-appview = {
168
162
type = "app";
169
-
program = ''${air-watcher "appview" ""}/bin/run'';
163
+
program = toString (pkgs.writeShellScript "watch-appview" ''
164
+
echo "copying static files to appview/pages/static..."
165
+
${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
166
+
${air-watcher "appview" ""}/bin/run
167
+
'');
170
168
};
171
169
watch-knot = {
172
170
type = "app";
···
176
174
type = "app";
177
175
program = ''${tailwind-watcher}/bin/run'';
178
176
};
179
-
vm = {
177
+
vm = let
178
+
system =
179
+
if pkgs.stdenv.hostPlatform.isAarch64
180
+
then "aarch64"
181
+
else "x86_64";
182
+
183
+
nixos-shell = pkgs.nixos-shell.overrideAttrs (old: {
184
+
patches =
185
+
(old.patches or [])
186
+
++ [
187
+
# https://github.com/Mic92/nixos-shell/pull/94
188
+
(pkgs.fetchpatch {
189
+
name = "fix-foreign-vm.patch";
190
+
url = "https://github.com/Mic92/nixos-shell/commit/113e4cc55ae236b5b0b1fbd8b321e9b67c77580e.patch";
191
+
hash = "sha256-eauetBK0wXAOcd9PYbExokNCiwz2QyFnZ4FnwGi9VCo=";
192
+
})
193
+
];
194
+
});
195
+
in {
180
196
type = "app";
181
197
program = toString (pkgs.writeShellScript "vm" ''
182
-
${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm
198
+
${nixos-shell}/bin/nixos-shell --flake .#vm-${system} --guest-system ${system}-linux
183
199
'');
184
200
};
185
201
gomod2nix = {
···
188
204
${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix
189
205
'');
190
206
};
207
+
lexgen = {
208
+
type = "app";
209
+
program =
210
+
(pkgs.writeShellApplication {
211
+
name = "lexgen";
212
+
text = ''
213
+
if ! command -v lexgen > /dev/null; then
214
+
echo "error: must be executed from devshell"
215
+
exit 1
216
+
fi
217
+
218
+
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
219
+
cd "$rootDir"
220
+
221
+
rm api/tangled/*
222
+
lexgen --build-file lexicon-build-config.json lexicons
223
+
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
224
+
${pkgs.gotools}/bin/goimports -w api/tangled/*
225
+
go run cmd/gen.go
226
+
lexgen --build-file lexicon-build-config.json lexicons
227
+
rm api/tangled/*.bak
228
+
'';
229
+
})
230
+
+ /bin/lexgen;
231
+
};
191
232
});
192
233
193
-
nixosModules.appview = import ./nix/modules/appview.nix {inherit self;};
194
-
nixosModules.knot = import ./nix/modules/knot.nix {inherit self;};
195
-
nixosModules.spindle = import ./nix/modules/spindle.nix {inherit self;};
196
-
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
234
+
nixosModules.appview = {
235
+
lib,
236
+
pkgs,
237
+
...
238
+
}: {
239
+
imports = [./nix/modules/appview.nix];
240
+
241
+
services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
242
+
};
243
+
nixosModules.knot = {
244
+
lib,
245
+
pkgs,
246
+
...
247
+
}: {
248
+
imports = [./nix/modules/knot.nix];
249
+
250
+
services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
251
+
};
252
+
nixosModules.spindle = {
253
+
lib,
254
+
pkgs,
255
+
...
256
+
}: {
257
+
imports = [./nix/modules/spindle.nix];
258
+
259
+
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
260
+
};
261
+
nixosConfigurations.vm-x86_64 = import ./nix/vm.nix {
262
+
inherit self nixpkgs;
263
+
system = "x86_64-linux";
264
+
};
265
+
nixosConfigurations.vm-aarch64 = import ./nix/vm.nix {
266
+
inherit self nixpkgs;
267
+
system = "aarch64-linux";
268
+
};
197
269
};
198
270
}
+55
-34
go.mod
+55
-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
91
+
github.com/gorilla/feeds v1.2.0 // indirect
82
92
github.com/gorilla/securecookie v1.1.2 // indirect
93
+
github.com/hashicorp/errwrap v1.1.0 // indirect
83
94
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
84
-
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
95
+
github.com/hashicorp/go-multierror v1.1.1 // indirect
96
+
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
97
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
98
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
99
+
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
85
100
github.com/hashicorp/golang-lru v1.0.2 // indirect
86
101
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
102
+
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
103
+
github.com/hexops/gotextdiff v1.0.3 // indirect
87
104
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
105
+
github.com/ipfs/boxo v0.33.0 // indirect
106
+
github.com/ipfs/go-block-format v0.2.2 // indirect
90
107
github.com/ipfs/go-datastore v0.8.2 // indirect
91
108
github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
92
109
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
110
+
github.com/ipfs/go-ipld-cbor v0.2.1 // indirect
111
+
github.com/ipfs/go-ipld-format v0.6.2 // indirect
95
112
github.com/ipfs/go-log v1.0.5 // indirect
96
113
github.com/ipfs/go-log/v2 v2.6.0 // indirect
97
114
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
98
115
github.com/kevinburke/ssh_config v1.2.0 // indirect
99
116
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
117
+
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
118
+
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
102
119
github.com/lestrrat-go/httpcc v1.0.1 // indirect
103
120
github.com/lestrrat-go/httprc v1.0.6 // indirect
104
121
github.com/lestrrat-go/iter v1.0.2 // indirect
105
122
github.com/lestrrat-go/option v1.0.1 // indirect
106
123
github.com/mattn/go-isatty v0.0.20 // indirect
107
124
github.com/minio/sha256-simd v1.0.1 // indirect
125
+
github.com/mitchellh/mapstructure v1.5.0 // indirect
108
126
github.com/moby/docker-image-spec v1.3.1 // indirect
109
127
github.com/moby/sys/atomicwriter v0.1.0 // indirect
110
128
github.com/moby/term v0.5.2 // indirect
···
116
134
github.com/multiformats/go-multihash v0.2.3 // indirect
117
135
github.com/multiformats/go-varint v0.0.7 // indirect
118
136
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
137
+
github.com/onsi/gomega v1.37.0 // indirect
119
138
github.com/opencontainers/go-digest v1.0.0 // indirect
120
139
github.com/opencontainers/image-spec v1.1.1 // indirect
121
-
github.com/opentracing/opentracing-go v1.2.0 // indirect
140
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
122
141
github.com/pjbgf/sha1cd v0.3.2 // indirect
123
142
github.com/pkg/errors v0.9.1 // indirect
124
143
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
125
144
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
126
145
github.com/prometheus/client_golang v1.22.0 // indirect
127
146
github.com/prometheus/client_model v0.6.2 // indirect
128
-
github.com/prometheus/common v0.63.0 // indirect
147
+
github.com/prometheus/common v0.64.0 // indirect
129
148
github.com/prometheus/procfs v0.16.1 // indirect
149
+
github.com/ryanuber/go-glob v1.0.0 // indirect
130
150
github.com/segmentio/asm v1.2.0 // indirect
131
151
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
132
152
github.com/spaolacci/murmur3 v1.1.0 // indirect
···
136
156
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
137
157
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
138
158
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
159
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
160
+
go.opentelemetry.io/otel v1.37.0 // indirect
161
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
162
+
go.opentelemetry.io/otel/metric v1.37.0 // indirect
163
+
go.opentelemetry.io/otel/trace v1.37.0 // indirect
143
164
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
144
165
go.uber.org/atomic v1.11.0 // indirect
145
166
go.uber.org/multierr v1.11.0 // indirect
146
167
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
168
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
169
+
golang.org/x/sys v0.34.0 // indirect
170
+
golang.org/x/text v0.27.0 // indirect
171
+
golang.org/x/time v0.12.0 // indirect
172
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
173
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
174
+
google.golang.org/grpc v1.73.0 // indirect
154
175
google.golang.org/protobuf v1.36.6 // indirect
155
176
gopkg.in/fsnotify.v1 v1.4.7 // indirect
156
177
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+131
-87
go.sum
+131
-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=
···
162
173
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
163
174
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
164
175
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
176
+
github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
177
+
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
165
178
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
166
179
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
167
180
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
168
181
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=
182
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
183
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
171
184
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
172
185
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
186
+
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
187
+
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
188
+
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
173
189
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
174
190
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
175
191
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
176
192
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=
193
+
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
194
+
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
195
+
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
196
+
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
197
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
198
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
199
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
200
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
201
+
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
202
+
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
179
203
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
180
204
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
181
205
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
182
206
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
207
+
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
208
+
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
183
209
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
184
210
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
185
211
github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw=
···
189
215
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
190
216
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
191
217
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=
218
+
github.com/ipfs/boxo v0.33.0 h1:9ow3chwkDzMj0Deq4AWRUEI7WnIIV7SZhPTzzG2mmfw=
219
+
github.com/ipfs/boxo v0.33.0/go.mod h1:3IPh7YFcCIcKp6o02mCHovrPntoT5Pctj/7j4syh/RM=
220
+
github.com/ipfs/go-block-format v0.2.2 h1:uecCTgRwDIXyZPgYspaLXoMiMmxQpSx2aq34eNc4YvQ=
221
+
github.com/ipfs/go-block-format v0.2.2/go.mod h1:vmuefuWU6b+9kIU0vZJgpiJt1yicQz9baHXE8qR+KB8=
196
222
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
197
223
github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
198
224
github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U=
···
205
231
github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo=
206
232
github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0=
207
233
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=
234
+
github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E=
235
+
github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A=
236
+
github.com/ipfs/go-ipld-format v0.6.2 h1:bPZQ+A05ol0b3lsJSl0bLvwbuQ+HQbSsdGTy4xtYUkU=
237
+
github.com/ipfs/go-ipld-format v0.6.2/go.mod h1:nni2xFdHKx5lxvXJ6brt/pndtGxKAE+FPR1rg4jTkyk=
212
238
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
213
239
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
214
240
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
···
216
242
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
217
243
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
218
244
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
245
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
222
246
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
223
247
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
···
229
253
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
230
254
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
231
255
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=
256
+
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
257
+
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
234
258
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
235
259
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
236
260
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
···
239
263
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
240
264
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
241
265
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=
266
+
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
267
+
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
244
268
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
245
269
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
246
270
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
···
251
275
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
252
276
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
253
277
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=
278
+
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
279
+
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
260
280
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
261
281
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
262
282
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
···
265
285
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
266
286
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
267
287
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
288
+
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
289
+
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
268
290
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
269
291
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
270
292
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
···
281
303
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
282
304
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
283
305
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
306
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
287
307
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
308
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
291
309
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
292
310
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
···
318
336
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
319
337
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
320
338
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=
339
+
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
340
+
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
341
+
github.com/openbao/openbao/api/v2 v2.3.0 h1:61FO3ILtpKoxbD9kTWeGaCq8pz1sdt4dv2cmTXsiaAc=
342
+
github.com/openbao/openbao/api/v2 v2.3.0/go.mod h1:T47WKHb7DqHa3Ms3xicQtl5EiPE+U8diKjb9888okWs=
323
343
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
324
344
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
325
345
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
326
346
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
347
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
348
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
349
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
329
350
github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU=
330
351
github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
331
352
github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo=
···
346
367
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
347
368
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
348
369
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=
370
+
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
371
+
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
351
372
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
352
373
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
353
374
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=
375
+
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
376
+
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
356
377
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
357
378
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
358
379
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
···
360
381
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
361
382
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
362
383
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
384
+
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
385
+
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
363
386
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
364
387
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
365
388
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
···
404
427
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
405
428
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
406
429
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
430
+
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
407
431
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
408
432
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
409
433
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
···
413
437
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
414
438
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
415
439
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=
440
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
441
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
442
+
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
443
+
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
444
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
445
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
422
446
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU=
423
447
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=
448
+
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
449
+
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
450
+
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
451
+
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
452
+
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
453
+
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
454
+
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
455
+
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
432
456
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
433
457
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
434
458
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
···
451
475
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
452
476
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
453
477
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=
478
+
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
479
+
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
480
+
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
481
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
482
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
458
483
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
459
484
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
460
485
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
461
486
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
487
+
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
462
488
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
463
489
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
464
490
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
465
491
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
492
+
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
466
493
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
467
494
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
468
495
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
···
471
498
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
472
499
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
473
500
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
501
+
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
474
502
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
475
503
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
476
504
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
···
480
508
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
481
509
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
482
510
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=
511
+
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
512
+
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
513
+
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
514
+
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
485
515
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
486
516
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
487
517
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
489
519
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
490
520
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
491
521
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=
522
+
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
523
+
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
494
524
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
495
525
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
496
526
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
502
532
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
503
533
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
504
534
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
535
+
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
505
536
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
537
+
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
506
538
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
507
539
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
508
540
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
510
542
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
511
543
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
512
544
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
545
+
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
513
546
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
514
547
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
515
548
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
516
549
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
550
+
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
517
551
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=
552
+
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
553
+
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
554
+
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
555
+
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
520
556
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
521
557
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
522
558
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
523
559
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
524
560
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
525
561
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=
562
+
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
563
+
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
564
+
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
565
+
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
566
+
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
528
567
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
529
568
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
530
569
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
···
532
571
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
533
572
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
534
573
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=
574
+
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
575
+
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
576
+
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
577
+
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
578
+
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
579
+
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
580
+
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
539
581
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
540
582
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
541
583
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
547
589
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
548
590
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
549
591
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
592
+
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
550
593
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
551
594
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
552
595
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
553
596
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
597
+
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
554
598
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
555
599
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
556
600
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
557
601
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
558
602
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
559
603
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=
604
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
605
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
606
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
607
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
608
+
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
609
+
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
566
610
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
567
611
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
568
612
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
···
599
643
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
600
644
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
601
645
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=
646
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU=
647
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg=
604
648
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
605
649
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
+
}
+13
-2
input.css
+13
-2
input.css
···
70
70
details summary::-webkit-details-marker {
71
71
display: none;
72
72
}
73
+
74
+
code {
75
+
@apply px-1 font-mono rounded bg-gray-100 dark:bg-gray-700;
76
+
}
73
77
}
74
78
75
79
@layer components {
···
100
104
101
105
.prose img {
102
106
display: inline;
103
-
margin-left: 0;
104
-
margin-right: 0;
107
+
margin: 0;
105
108
vertical-align: middle;
109
+
}
110
+
111
+
.prose input {
112
+
@apply inline-block my-0 mb-1 mx-1;
113
+
}
114
+
115
+
.prose input[type="checkbox"] {
116
+
@apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500;
106
117
}
107
118
}
108
119
@layer utilities {
+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
}
+19
-12
knotserver/git/post_receive.go
+19
-12
knotserver/git/post_receive.go
···
3
3
import (
4
4
"bufio"
5
5
"context"
6
+
"errors"
6
7
"fmt"
7
8
"io"
8
9
"strings"
···
57
58
ByEmail map[string]int
58
59
}
59
60
60
-
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta {
61
+
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) {
62
+
var errs error
63
+
61
64
commitCount, err := g.newCommitCount(line)
62
-
if err != nil {
63
-
// TODO: log this
64
-
}
65
+
errors.Join(errs, err)
65
66
66
67
isDefaultRef, err := g.isDefaultBranch(line)
67
-
if err != nil {
68
-
// TODO: log this
69
-
}
68
+
errors.Join(errs, err)
70
69
71
70
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
72
71
defer cancel()
73
72
breakdown, err := g.AnalyzeLanguages(ctx)
74
-
if err != nil {
75
-
// TODO: log this
76
-
}
73
+
errors.Join(errs, err)
77
74
78
75
return RefUpdateMeta{
79
76
CommitCount: commitCount,
80
77
IsDefaultRef: isDefaultRef,
81
78
LangBreakdown: breakdown,
82
-
}
79
+
}, errs
83
80
}
84
81
85
82
func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) {
···
95
92
args := []string{fmt.Sprintf("--max-count=%d", 100)}
96
93
97
94
if line.OldSha.IsZero() {
98
-
// just git rev-list <newsha>
95
+
// git rev-list <newsha> ^other-branches --not ^this-branch
99
96
args = append(args, line.NewSha.String())
97
+
98
+
branches, _ := g.Branches()
99
+
for _, b := range branches {
100
+
if !strings.Contains(line.Ref, b.Name) {
101
+
args = append(args, fmt.Sprintf("^%s", b.Name))
102
+
}
103
+
}
104
+
105
+
args = append(args, "--not")
106
+
args = append(args, fmt.Sprintf("^%s", line.Ref))
100
107
} else {
101
108
// git rev-list <oldsha>..<newsha>
102
109
args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()))
+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
+67
-7
knotserver/internal.go
+67
-7
knotserver/internal.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
8
"log/slog"
8
9
"net/http"
···
13
14
"github.com/go-chi/chi/v5"
14
15
"github.com/go-chi/chi/v5/middleware"
15
16
"tangled.sh/tangled.sh/core/api/tangled"
17
+
"tangled.sh/tangled.sh/core/hook"
16
18
"tangled.sh/tangled.sh/core/knotserver/config"
17
19
"tangled.sh/tangled.sh/core/knotserver/db"
18
20
"tangled.sh/tangled.sh/core/knotserver/git"
···
38
40
return
39
41
}
40
42
41
-
ok, err := h.e.IsPushAllowed(user, ThisServer, repo)
43
+
ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
42
44
if err != nil || !ok {
43
45
w.WriteHeader(http.StatusForbidden)
44
46
return
···
64
66
return
65
67
}
66
68
69
+
type PushOptions struct {
70
+
skipCi bool
71
+
verboseCi bool
72
+
}
73
+
67
74
func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
68
75
l := h.l.With("handler", "PostReceiveHook")
69
76
···
90
97
// non-fatal
91
98
}
92
99
100
+
// extract any push options
101
+
pushOptionsRaw := r.Header.Values("X-Git-Push-Option")
102
+
pushOptions := PushOptions{}
103
+
for _, option := range pushOptionsRaw {
104
+
if option == "skip-ci" || option == "ci-skip" {
105
+
pushOptions.skipCi = true
106
+
}
107
+
if option == "verbose-ci" || option == "ci-verbose" {
108
+
pushOptions.verboseCi = true
109
+
}
110
+
}
111
+
112
+
resp := hook.HookResponse{
113
+
Messages: make([]string, 0),
114
+
}
115
+
93
116
for _, line := range lines {
94
117
err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
95
118
if err != nil {
···
97
120
// non-fatal
98
121
}
99
122
100
-
err = h.triggerPipeline(line, gitUserDid, repoDid, repoName)
123
+
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
101
124
if err != nil {
102
125
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
103
126
// non-fatal
104
127
}
105
128
}
129
+
130
+
writeJSON(w, resp)
106
131
}
107
132
108
133
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
···
121
146
return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
122
147
}
123
148
124
-
meta := gr.RefUpdateMeta(line)
149
+
var errs error
150
+
meta, err := gr.RefUpdateMeta(line)
151
+
errors.Join(errs, err)
125
152
126
153
metaRecord := meta.AsRecord()
127
154
···
145
172
EventJson: string(eventJson),
146
173
}
147
174
148
-
return h.db.InsertEvent(event, h.n)
175
+
return errors.Join(errs, h.db.InsertEvent(event, h.n))
149
176
}
150
177
151
-
func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
178
+
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
179
+
if pushOptions.skipCi {
180
+
return nil
181
+
}
182
+
152
183
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
153
184
if err != nil {
154
185
return err
···
169
200
return err
170
201
}
171
202
203
+
pipelineParseErrors := []string{}
204
+
172
205
var pipeline workflow.Pipeline
173
206
for _, e := range workflowDir {
174
207
if !e.IsFile {
···
183
216
184
217
wf, err := workflow.FromFile(e.Name, contents)
185
218
if err != nil {
186
-
// TODO: log here, respond to client that is pushing
187
219
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
220
+
pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err))
188
221
continue
189
222
}
190
223
···
209
242
},
210
243
}
211
244
212
-
// TODO: send the diagnostics back to the user here via stderr
213
245
cp := compiler.Compile(pipeline)
214
246
eventJson, err := json.Marshal(cp)
215
247
if err != nil {
216
248
return err
249
+
}
250
+
251
+
if pushOptions.verboseCi {
252
+
hasDiagnostics := false
253
+
if len(pipelineParseErrors) > 0 {
254
+
hasDiagnostics = true
255
+
*clientMsgs = append(*clientMsgs, "error: failed to parse workflow(s):")
256
+
for _, error := range pipelineParseErrors {
257
+
*clientMsgs = append(*clientMsgs, error)
258
+
}
259
+
}
260
+
if len(compiler.Diagnostics.Errors) > 0 {
261
+
hasDiagnostics = true
262
+
*clientMsgs = append(*clientMsgs, "error(s) on pipeline:")
263
+
for _, error := range compiler.Diagnostics.Errors {
264
+
*clientMsgs = append(*clientMsgs, fmt.Sprintf("- %s:", error))
265
+
}
266
+
}
267
+
if len(compiler.Diagnostics.Warnings) > 0 {
268
+
hasDiagnostics = true
269
+
*clientMsgs = append(*clientMsgs, "warning(s) on pipeline:")
270
+
for _, warning := range compiler.Diagnostics.Warnings {
271
+
*clientMsgs = append(*clientMsgs, fmt.Sprintf("- at %s: %s: %s", warning.Path, warning.Type, warning.Reason))
272
+
}
273
+
}
274
+
if !hasDiagnostics {
275
+
*clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
276
+
}
217
277
}
218
278
219
279
// do not run empty pipelines
+28
-11
knotserver/routes.go
+28
-11
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
···
352
361
353
362
ref := strings.TrimSuffix(file, ".tar.gz")
354
363
364
+
unescapedRef, err := url.PathUnescape(ref)
365
+
if err != nil {
366
+
notFound(w)
367
+
return
368
+
}
369
+
370
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
371
+
355
372
// This allows the browser to use a proper name for the file when
356
373
// downloading
357
-
filename := fmt.Sprintf("%s-%s.tar.gz", name, ref)
374
+
filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename)
358
375
setContentDisposition(w, filename)
359
376
setGZipMIME(w)
360
377
361
378
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
362
-
gr, err := git.Open(path, ref)
379
+
gr, err := git.Open(path, unescapedRef)
363
380
if err != nil {
364
381
notFound(w)
365
382
return
···
368
385
gw := gzip.NewWriter(w)
369
386
defer gw.Close()
370
387
371
-
prefix := fmt.Sprintf("%s-%s", name, ref)
388
+
prefix := fmt.Sprintf("%s-%s", name, safeRefFilename)
372
389
err = gr.WriteTar(gw, prefix)
373
390
if err != nil {
374
391
// once we start writing to the body we can't report error anymore
···
674
691
}
675
692
676
693
// add perms for this user to access the repo
677
-
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
694
+
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
678
695
if err != nil {
679
696
l.Error("adding repo permissions", "error", err.Error())
680
697
writeError(w, err.Error(), http.StatusInternalServerError)
···
892
909
}
893
910
894
911
// add perms for this user to access the repo
895
-
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
912
+
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
896
913
if err != nil {
897
914
l.Error("adding repo permissions", "error", err.Error())
898
915
writeError(w, err.Error(), http.StatusInternalServerError)
···
1146
1163
}
1147
1164
h.jc.AddDid(did)
1148
1165
1149
-
if err := h.e.AddKnotMember(ThisServer, did); err != nil {
1166
+
if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil {
1150
1167
l.Error("adding member", "error", err.Error())
1151
1168
writeError(w, err.Error(), http.StatusInternalServerError)
1152
1169
return
···
1184
1201
h.jc.AddDid(data.Did)
1185
1202
1186
1203
repoName, _ := securejoin.SecureJoin(ownerDid, repo)
1187
-
if err := h.e.AddCollaborator(data.Did, ThisServer, repoName); err != nil {
1204
+
if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil {
1188
1205
l.Error("adding repo collaborator", "error", err.Error())
1189
1206
writeError(w, err.Error(), http.StatusInternalServerError)
1190
1207
return
···
1281
1298
}
1282
1299
h.jc.AddDid(data.Did)
1283
1300
1284
-
if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil {
1301
+
if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil {
1285
1302
l.Error("adding owner", "error", err.Error())
1286
1303
writeError(w, err.Error(), http.StatusInternalServerError)
1287
1304
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
+
}
+128
-59
nix/gomod2nix.toml
+128
-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="
69
+
[mod."github.com/cloudflare/cloudflare-go"]
70
+
version = "v0.115.0"
71
+
hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw="
60
72
[mod."github.com/containerd/errdefs"]
61
73
version = "v1.0.0"
62
74
hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI="
···
105
117
[mod."github.com/felixge/httpsnoop"]
106
118
version = "v1.0.4"
107
119
hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c="
120
+
[mod."github.com/fsnotify/fsnotify"]
121
+
version = "v1.6.0"
122
+
hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0="
108
123
[mod."github.com/gliderlabs/ssh"]
109
124
version = "v0.3.8"
110
125
hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc="
···
127
142
version = "v5.17.0"
128
143
hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ="
129
144
replaced = "github.com/oppiliappan/go-git/v5"
145
+
[mod."github.com/go-jose/go-jose/v3"]
146
+
version = "v3.0.4"
147
+
hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ="
130
148
[mod."github.com/go-logr/logr"]
131
-
version = "v1.4.2"
132
-
hash = "sha256-/W6qGilFlZNTb9Uq48xGZ4IbsVeSwJiAMLw4wiNYHLI="
149
+
version = "v1.4.3"
150
+
hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA="
133
151
[mod."github.com/go-logr/stdr"]
134
152
version = "v1.2.2"
135
153
hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE="
136
154
[mod."github.com/go-redis/cache/v9"]
137
155
version = "v9.0.0"
138
156
hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY="
157
+
[mod."github.com/go-test/deep"]
158
+
version = "v1.1.1"
159
+
hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8="
139
160
[mod."github.com/goccy/go-json"]
140
161
version = "v0.10.5"
141
162
hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw="
···
143
164
version = "v1.3.2"
144
165
hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c="
145
166
[mod."github.com/golang-jwt/jwt/v5"]
146
-
version = "v5.2.2"
147
-
hash = "sha256-C0MhDguxWR6dQUrNVQ5xaFUReSV6CVEBAijG3b4wnX4="
167
+
version = "v5.2.3"
168
+
hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo="
148
169
[mod."github.com/golang/groupcache"]
149
170
version = "v0.0.0-20241129210726-2c02b8208cf8"
150
171
hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74="
172
+
[mod."github.com/golang/mock"]
173
+
version = "v1.6.0"
174
+
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
175
+
[mod."github.com/google/go-querystring"]
176
+
version = "v1.1.0"
177
+
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
151
178
[mod."github.com/google/uuid"]
152
179
version = "v1.6.0"
153
180
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
154
181
[mod."github.com/gorilla/css"]
155
182
version = "v1.0.1"
156
183
hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A="
184
+
[mod."github.com/gorilla/feeds"]
185
+
version = "v1.2.0"
186
+
hash = "sha256-ptczizo27t6Bsq6rHJ4WiHmBRP54UC5yNfHghAqOBQk="
157
187
[mod."github.com/gorilla/securecookie"]
158
188
version = "v1.1.2"
159
189
hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE="
···
161
191
version = "v1.4.0"
162
192
hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g="
163
193
[mod."github.com/gorilla/websocket"]
164
-
version = "v1.5.3"
165
-
hash = "sha256-vTIGEFMEi+30ZdO6ffMNJ/kId6pZs5bbyqov8xe9BM0="
194
+
version = "v1.5.4-0.20250319132907-e064f32e3674"
195
+
hash = "sha256-a8n6oe20JDpwThClgAyVhJDi6QVaS0qzT4PvRxlQ9to="
196
+
[mod."github.com/hashicorp/errwrap"]
197
+
version = "v1.1.0"
198
+
hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw="
166
199
[mod."github.com/hashicorp/go-cleanhttp"]
167
200
version = "v0.5.2"
168
201
hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ="
202
+
[mod."github.com/hashicorp/go-multierror"]
203
+
version = "v1.1.1"
204
+
hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA="
169
205
[mod."github.com/hashicorp/go-retryablehttp"]
170
-
version = "v0.7.7"
171
-
hash = "sha256-XZjxncyLPwy6YBHR3DF5bEl1y72or0JDUncTIsb/eIU="
206
+
version = "v0.7.8"
207
+
hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80="
208
+
[mod."github.com/hashicorp/go-secure-stdlib/parseutil"]
209
+
version = "v0.2.0"
210
+
hash = "sha256-mb27ZKw5VDTmNj1QJvxHVR0GyY7UdacLJ0jWDV3nQd8="
211
+
[mod."github.com/hashicorp/go-secure-stdlib/strutil"]
212
+
version = "v0.1.2"
213
+
hash = "sha256-UmCMzjamCW1d9KNvNzELqKf1ElHOXPz+ZtdJkI+DV0A="
214
+
[mod."github.com/hashicorp/go-sockaddr"]
215
+
version = "v1.0.7"
216
+
hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs="
172
217
[mod."github.com/hashicorp/golang-lru"]
173
218
version = "v1.0.2"
174
219
hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="
175
220
[mod."github.com/hashicorp/golang-lru/v2"]
176
221
version = "v2.0.7"
177
222
hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g="
223
+
[mod."github.com/hashicorp/hcl"]
224
+
version = "v1.0.1-vault-7"
225
+
hash = "sha256-xqYtjCJQVsg04Yj2Uy2Q5bi6X6cDRYhJD/SUEWaHMDM="
226
+
[mod."github.com/hexops/gotextdiff"]
227
+
version = "v1.0.3"
228
+
hash = "sha256-wVs5uJs2KHU1HnDCDdSe0vIgNZylvs8oNidDxwA3+O0="
178
229
[mod."github.com/hiddeco/sshsig"]
179
230
version = "v0.2.0"
180
231
hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU="
···
185
236
version = "v0.0.4"
186
237
hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU="
187
238
[mod."github.com/ipfs/boxo"]
188
-
version = "v0.30.0"
189
-
hash = "sha256-PWH+nlIZZlqB/PuiBX9X4McLZF4gKR1MEnjvutKT848="
239
+
version = "v0.33.0"
240
+
hash = "sha256-C85D/TjyoWNKvRFvl2A9hBjpDPhC//hGB2jRoDmXM38="
190
241
[mod."github.com/ipfs/go-block-format"]
191
-
version = "v0.2.1"
192
-
hash = "sha256-npEV0Axe6zJlzN00/GwiegE9HKsuDR6RhsAfPyphOl8="
242
+
version = "v0.2.2"
243
+
hash = "sha256-kz87tlGneqEARGnjA1Lb0K5CqD1lgxhBD68rfmzZZGU="
193
244
[mod."github.com/ipfs/go-cid"]
194
245
version = "v0.5.0"
195
246
hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk="
···
203
254
version = "v1.1.1"
204
255
hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY="
205
256
[mod."github.com/ipfs/go-ipld-cbor"]
206
-
version = "v0.2.0"
207
-
hash = "sha256-bvHFCIQqim3/+xzl1bld3NxKY8WoeCO3HpdTfUsXvlc="
257
+
version = "v0.2.1"
258
+
hash = "sha256-ONBX/YO/knnmp+12fC13KsKVeo/vdWOI3SDyqCBxRE4="
208
259
[mod."github.com/ipfs/go-ipld-format"]
209
-
version = "v0.6.1"
210
-
hash = "sha256-v1zLYYGaoDxsgOW5joQGWHEHZoJjIXc6tLVgTomZ2z4="
260
+
version = "v0.6.2"
261
+
hash = "sha256-nGLM/n/hy+0q1VIzQUvF4D3aKvL238ALIEziQQhVMkU="
211
262
[mod."github.com/ipfs/go-log"]
212
263
version = "v1.0.5"
213
264
hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4="
···
224
275
version = "v1.18.0"
225
276
hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk="
226
277
[mod."github.com/klauspost/cpuid/v2"]
227
-
version = "v2.2.10"
228
-
hash = "sha256-o21Tk5sD7WhhLUoqSkymnjLbzxl0mDJCTC1ApfZJrC0="
278
+
version = "v2.3.0"
279
+
hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc="
229
280
[mod."github.com/lestrrat-go/blackmagic"]
230
-
version = "v1.0.3"
231
-
hash = "sha256-1wyfD6fPopJF/UmzfAEa0N1zuUzVuHIpdcxks1kqxxw="
281
+
version = "v1.0.4"
282
+
hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8="
232
283
[mod."github.com/lestrrat-go/httpcc"]
233
284
version = "v1.0.1"
234
285
hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos="
···
256
307
[mod."github.com/minio/sha256-simd"]
257
308
version = "v1.0.1"
258
309
hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA="
310
+
[mod."github.com/mitchellh/mapstructure"]
311
+
version = "v1.5.0"
312
+
hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE="
259
313
[mod."github.com/moby/docker-image-spec"]
260
314
version = "v1.3.1"
261
315
hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs="
···
289
343
[mod."github.com/munnerz/goautoneg"]
290
344
version = "v0.0.0-20191010083416-a7dc8b61c822"
291
345
hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q="
346
+
[mod."github.com/onsi/gomega"]
347
+
version = "v1.37.0"
348
+
hash = "sha256-PfHFYp365MwBo+CUZs+mN5QEk3Kqe9xrBX+twWfIc9o="
349
+
[mod."github.com/openbao/openbao/api/v2"]
350
+
version = "v2.3.0"
351
+
hash = "sha256-1bIyvL3GdzPUfsM+gxuKMaH5jKxMaucZQgL6/DfbmDM="
292
352
[mod."github.com/opencontainers/go-digest"]
293
353
version = "v1.0.0"
294
354
hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ="
···
296
356
version = "v1.1.1"
297
357
hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8="
298
358
[mod."github.com/opentracing/opentracing-go"]
299
-
version = "v1.2.0"
300
-
hash = "sha256-kKTKFGXOsCF6QdVzI++GgaRzv2W+kWq5uDXOJChvLxM="
359
+
version = "v1.2.1-0.20220228012449-10b1cf09e00b"
360
+
hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw="
301
361
[mod."github.com/pjbgf/sha1cd"]
302
362
version = "v0.3.2"
303
363
hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk="
···
320
380
version = "v0.6.2"
321
381
hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ="
322
382
[mod."github.com/prometheus/common"]
323
-
version = "v0.63.0"
324
-
hash = "sha256-TbUZNkN4ZA7eC/MlL1v2V5OL28QRnftSuaWQZ944zBE="
383
+
version = "v0.64.0"
384
+
hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI="
325
385
[mod."github.com/prometheus/procfs"]
326
386
version = "v0.16.1"
327
387
hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo="
328
388
[mod."github.com/redis/go-redis/v9"]
329
-
version = "v9.3.0"
330
-
hash = "sha256-PNXDX3BH92d2jL/AkdK0eWMorh387Y6duwYNhsqNe+w="
389
+
version = "v9.7.3"
390
+
hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo="
331
391
[mod."github.com/resend/resend-go/v2"]
332
392
version = "v2.15.0"
333
393
hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg="
394
+
[mod."github.com/ryanuber/go-glob"]
395
+
version = "v1.0.0"
396
+
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
334
397
[mod."github.com/segmentio/asm"]
335
398
version = "v1.2.0"
336
399
hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs="
···
375
438
version = "v1.1.0"
376
439
hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
377
440
[mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"]
378
-
version = "v0.61.0"
379
-
hash = "sha256-4pfXD7ErXhexSynXiEEQSAkWoPwHd7PEDE3M1Zi5gLM="
441
+
version = "v0.62.0"
442
+
hash = "sha256-WcDogpsvFxGOVqc+/ljlZ10nrOU2q0rPteukGyWWmfc="
380
443
[mod."go.opentelemetry.io/otel"]
381
-
version = "v1.36.0"
382
-
hash = "sha256-j8wojdCtKal3LKojanHA8KXXQ0FkbWONpO8tUxpJDko="
444
+
version = "v1.37.0"
445
+
hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo="
446
+
[mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"]
447
+
version = "v1.33.0"
448
+
hash = "sha256-D5BMzmtN1d3pRnxIcvDOyQrjerK1JoavtYjJLhPKv/I="
383
449
[mod."go.opentelemetry.io/otel/metric"]
384
-
version = "v1.36.0"
385
-
hash = "sha256-z6Uqi4HhUljWIYd58svKK5MqcGbpcac+/M8JeTrUtJ8="
450
+
version = "v1.37.0"
451
+
hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg="
386
452
[mod."go.opentelemetry.io/otel/trace"]
387
-
version = "v1.36.0"
388
-
hash = "sha256-owWD9x1lp8aIJqYt058BXPUsIMHdk3RI0escso0BxwA="
453
+
version = "v1.37.0"
454
+
hash = "sha256-FBeLOb5qmIiE9VmbgCf1l/xpndBqHkRiaPt1PvoKrVY="
389
455
[mod."go.opentelemetry.io/proto/otlp"]
390
456
version = "v1.6.0"
391
457
hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg="
···
399
465
version = "v1.27.0"
400
466
hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU="
401
467
[mod."golang.org/x/crypto"]
402
-
version = "v0.38.0"
403
-
hash = "sha256-5tTXlXQBlfW1sSNDAIalOpsERbTJlZqbwCIiih4T4rY="
468
+
version = "v0.40.0"
469
+
hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng="
404
470
[mod."golang.org/x/exp"]
405
-
version = "v0.0.0-20250408133849-7e4ce0ab07d0"
406
-
hash = "sha256-Lw/WupSM8gcq0JzPSAaBqj9l1uZ68ANhaIaQzPhRpy8="
471
+
version = "v0.0.0-20250620022241-b7579e27df2b"
472
+
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
407
473
[mod."golang.org/x/net"]
408
-
version = "v0.40.0"
409
-
hash = "sha256-BhDOHTP8RekXDQDf9HlORSmI2aPacLo53fRXtTgCUH8="
474
+
version = "v0.42.0"
475
+
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
410
476
[mod."golang.org/x/sync"]
411
-
version = "v0.14.0"
412
-
hash = "sha256-YNQLeFMeXN9y0z4OyXV/LJ4hA54q+ljm1ytcy80O6r4="
477
+
version = "v0.16.0"
478
+
hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks="
413
479
[mod."golang.org/x/sys"]
414
-
version = "v0.33.0"
415
-
hash = "sha256-wlOzIOUgAiGAtdzhW/KPl/yUVSH/lvFZfs5XOuJ9LOQ="
480
+
version = "v0.34.0"
481
+
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
482
+
[mod."golang.org/x/text"]
483
+
version = "v0.27.0"
484
+
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
416
485
[mod."golang.org/x/time"]
417
-
version = "v0.8.0"
418
-
hash = "sha256-EA+qRisDJDPQ2g4pcfP4RyQaB7CJKkAn68EbNfBzXdQ="
486
+
version = "v0.12.0"
487
+
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
419
488
[mod."golang.org/x/xerrors"]
420
489
version = "v0.0.0-20240903120638-7835f813f4da"
421
490
hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo="
422
491
[mod."google.golang.org/genproto/googleapis/api"]
423
-
version = "v0.0.0-20250519155744-55703ea1f237"
424
-
hash = "sha256-ivktx8ipWgWZgchh4FjKoWL7kU8kl/TtIavtZq/F5SQ="
492
+
version = "v0.0.0-20250603155806-513f23925822"
493
+
hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU="
425
494
[mod."google.golang.org/genproto/googleapis/rpc"]
426
-
version = "v0.0.0-20250519155744-55703ea1f237"
495
+
version = "v0.0.0-20250603155806-513f23925822"
427
496
hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM="
428
497
[mod."google.golang.org/grpc"]
429
-
version = "v1.72.1"
430
-
hash = "sha256-5JczomNvroKWtIYKDgXwaIaEfuNEK//MHPhJQiaxMXs="
498
+
version = "v1.73.0"
499
+
hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c="
431
500
[mod."google.golang.org/protobuf"]
432
501
version = "v1.36.6"
433
502
hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc="
···
450
519
version = "v1.4.1"
451
520
hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
452
521
[mod."tangled.sh/icyphox.sh/atproto-oauth"]
453
-
version = "v0.0.0-20250526154904-3906c5336421"
454
-
hash = "sha256-CvR8jic0YZfj0a8ubPj06FiMMR/1K9kHoZhLQw1LItM="
522
+
version = "v0.0.0-20250724194903-28e660378cb1"
523
+
hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+54
-35
nix/modules/appview.nix
+54
-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
+
};
30
+
environmentFile = mkOption {
31
+
type = with types; nullOr path;
32
+
default = null;
33
+
example = "/etc/tangled-appview.env";
34
+
description = ''
35
+
Additional environment file as defined in {manpage}`systemd.exec(5)`.
36
+
37
+
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
38
+
passed to the service without makeing them world readable in the
39
+
nix store.
40
+
41
+
'';
42
+
};
24
43
};
25
44
};
26
-
};
27
45
28
-
config = mkIf config.services.tangled-appview.enable {
29
-
systemd.services.tangled-appview = {
30
-
description = "tangled appview service";
31
-
wantedBy = ["multi-user.target"];
46
+
config = mkIf cfg.enable {
47
+
systemd.services.tangled-appview = {
48
+
description = "tangled appview service";
49
+
wantedBy = ["multi-user.target"];
32
50
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
-
};
51
+
serviceConfig = {
52
+
ListenStream = "0.0.0.0:${toString cfg.port}";
53
+
ExecStart = "${cfg.package}/bin/appview";
54
+
Restart = "always";
55
+
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
56
+
};
38
57
39
-
environment = {
40
-
TANGLED_DB_PATH = "appview.db";
41
-
TANGLED_COOKIE_SECRET = config.services.tangled-appview.cookie_secret;
58
+
environment = {
59
+
TANGLED_DB_PATH = "appview.db";
60
+
TANGLED_COOKIE_SECRET = cfg.cookie_secret;
61
+
};
42
62
};
43
63
};
44
-
};
45
-
}
64
+
}
+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
};
+28
-3
nix/modules/spindle.nix
+28
-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 = {
···
51
54
example = "did:plc:qfpnj4og54vl56wngdriaxug";
52
55
description = "DID of owner (required)";
53
56
};
57
+
58
+
secrets = {
59
+
provider = mkOption {
60
+
type = types.str;
61
+
default = "sqlite";
62
+
description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'.";
63
+
};
64
+
65
+
openbao = {
66
+
proxyAddr = mkOption {
67
+
type = types.str;
68
+
default = "http://127.0.0.1:8200";
69
+
};
70
+
mount = mkOption {
71
+
type = types.str;
72
+
default = "spindle";
73
+
};
74
+
};
75
+
};
54
76
};
55
77
56
78
pipelines = {
···
86
108
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
87
109
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
88
110
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
111
+
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
112
+
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
113
+
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
89
114
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
90
115
"SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
91
116
];
92
-
ExecStart = "${self.packages.${pkgs.system}.spindle}/bin/spindle";
117
+
ExecStart = "${cfg.package}/bin/spindle";
93
118
Restart = "always";
94
119
};
95
120
};
+29
nix/pkgs/appview-static-files.nix
+29
nix/pkgs/appview-static-files.nix
···
1
+
{
2
+
runCommandLocal,
3
+
htmx-src,
4
+
htmx-ws-src,
5
+
lucide-src,
6
+
inter-fonts-src,
7
+
ibm-plex-mono-src,
8
+
sqlite-lib,
9
+
tailwindcss,
10
+
src,
11
+
}:
12
+
runCommandLocal "appview-static-files" {
13
+
# TOOD(winter): figure out why this is even required after
14
+
# changing the libraries that the tailwindcss binary loads
15
+
sandboxProfile = ''
16
+
(allow file-read* (subpath "/System/Library/OpenSSL"))
17
+
'';
18
+
} ''
19
+
mkdir -p $out/{fonts,icons} && cd $out
20
+
cp -f ${htmx-src} htmx.min.js
21
+
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
22
+
cp -rf ${lucide-src}/*.svg icons/
23
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
24
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
25
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/
26
+
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
27
+
# for whatever reason (produces broken css), so we are doing this instead
28
+
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
29
+
''
+5
-17
nix/pkgs/appview.nix
+5
-17
nix/pkgs/appview.nix
···
1
1
{
2
2
buildGoApplication,
3
3
modules,
4
-
htmx-src,
5
-
htmx-ws-src,
6
-
lucide-src,
7
-
inter-fonts-src,
8
-
ibm-plex-mono-src,
9
-
tailwindcss,
4
+
appview-static-files,
10
5
sqlite-lib,
11
-
gitignoreSource,
6
+
src,
12
7
}:
13
8
buildGoApplication {
14
9
pname = "appview";
15
10
version = "0.1.0";
16
-
src = gitignoreSource ../..;
17
-
inherit modules;
11
+
inherit src modules;
18
12
19
13
postUnpack = ''
20
14
pushd source
21
-
mkdir -p appview/pages/static/{fonts,icons}
22
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
23
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
24
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
25
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
26
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
27
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
28
-
${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css
15
+
mkdir -p appview/pages/static
16
+
cp -frv ${appview-static-files}/* appview/pages/static
29
17
popd
30
18
'';
31
19
+7
-3
nix/pkgs/genjwks.nix
+7
-3
nix/pkgs/genjwks.nix
···
1
1
{
2
-
gitignoreSource,
3
2
buildGoApplication,
4
3
modules,
5
4
}:
6
5
buildGoApplication {
7
6
pname = "genjwks";
8
7
version = "0.1.0";
9
-
src = gitignoreSource ../..;
8
+
src = ../../cmd/genjwks;
9
+
postPatch = ''
10
+
ln -s ${../../go.mod} ./go.mod
11
+
'';
12
+
postInstall = ''
13
+
mv $out/bin/core $out/bin/genjwks
14
+
'';
10
15
inherit modules;
11
-
subPackages = ["cmd/genjwks"];
12
16
doCheck = false;
13
17
CGO_ENABLED = 0;
14
18
}
+2
-3
nix/pkgs/knot-unwrapped.nix
+2
-3
nix/pkgs/knot-unwrapped.nix
+1
-1
nix/pkgs/lexgen.nix
+1
-1
nix/pkgs/lexgen.nix
+2
-3
nix/pkgs/spindle.nix
+2
-3
nix/pkgs/spindle.nix
+81
-62
nix/vm.nix
+81
-62
nix/vm.nix
···
1
1
{
2
2
nixpkgs,
3
+
system,
3
4
self,
4
-
}:
5
-
nixpkgs.lib.nixosSystem {
6
-
system = "x86_64-linux";
7
-
modules = [
8
-
self.nixosModules.knot
9
-
self.nixosModules.spindle
10
-
({
11
-
config,
12
-
pkgs,
13
-
...
14
-
}: {
15
-
virtualisation = {
16
-
memorySize = 2048;
17
-
diskSize = 10 * 1024;
18
-
cores = 2;
19
-
forwardPorts = [
20
-
# ssh
21
-
{
22
-
from = "host";
23
-
host.port = 2222;
24
-
guest.port = 22;
25
-
}
26
-
# knot
27
-
{
28
-
from = "host";
29
-
host.port = 6000;
30
-
guest.port = 6000;
31
-
}
32
-
# spindle
33
-
{
34
-
from = "host";
35
-
host.port = 6555;
36
-
guest.port = 6555;
37
-
}
5
+
}: let
6
+
envVar = name: let
7
+
var = builtins.getEnv name;
8
+
in
9
+
if var == ""
10
+
then throw "\$${name} must be defined, see docs/hacking.md for more details"
11
+
else var;
12
+
in
13
+
nixpkgs.lib.nixosSystem {
14
+
inherit system;
15
+
modules = [
16
+
self.nixosModules.knot
17
+
self.nixosModules.spindle
18
+
({
19
+
config,
20
+
pkgs,
21
+
...
22
+
}: {
23
+
nixos-shell = {
24
+
inheritPath = false;
25
+
mounts = {
26
+
mountHome = false;
27
+
mountNixProfile = false;
28
+
};
29
+
};
30
+
virtualisation = {
31
+
memorySize = 2048;
32
+
diskSize = 10 * 1024;
33
+
cores = 2;
34
+
forwardPorts = [
35
+
# ssh
36
+
{
37
+
from = "host";
38
+
host.port = 2222;
39
+
guest.port = 22;
40
+
}
41
+
# knot
42
+
{
43
+
from = "host";
44
+
host.port = 6000;
45
+
guest.port = 6000;
46
+
}
47
+
# spindle
48
+
{
49
+
from = "host";
50
+
host.port = 6555;
51
+
guest.port = 6555;
52
+
}
53
+
];
54
+
};
55
+
services.getty.autologinUser = "root";
56
+
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
57
+
systemd.tmpfiles.rules = let
58
+
u = config.services.tangled-knot.gitUser;
59
+
g = config.services.tangled-knot.gitUser;
60
+
in [
61
+
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
62
+
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=${envVar "TANGLED_VM_KNOT_SECRET"}"
38
63
];
39
-
};
40
-
services.getty.autologinUser = "root";
41
-
environment.systemPackages = with pkgs; [curl vim git];
42
-
systemd.tmpfiles.rules = let
43
-
u = config.services.tangled-knot.gitUser;
44
-
g = config.services.tangled-knot.gitUser;
45
-
in [
46
-
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
47
-
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440"
48
-
];
49
-
services.tangled-knot = {
50
-
enable = true;
51
-
server = {
52
-
secretFile = "/var/lib/knot/secret";
53
-
hostname = "localhost:6000";
54
-
listenAddr = "0.0.0.0:6000";
64
+
services.tangled-knot = {
65
+
enable = true;
66
+
motd = "Welcome to the development knot!\n";
67
+
server = {
68
+
secretFile = "/var/lib/knot/secret";
69
+
hostname = "localhost:6000";
70
+
listenAddr = "0.0.0.0:6000";
71
+
};
55
72
};
56
-
};
57
-
services.tangled-spindle = {
58
-
enable = true;
59
-
server = {
60
-
owner = "did:plc:qfpnj4og54vl56wngdriaxug";
61
-
hostname = "localhost:6555";
62
-
listenAddr = "0.0.0.0:6555";
63
-
dev = true;
73
+
services.tangled-spindle = {
74
+
enable = true;
75
+
server = {
76
+
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
77
+
hostname = "localhost:6555";
78
+
listenAddr = "0.0.0.0:6555";
79
+
dev = true;
80
+
secrets = {
81
+
provider = "sqlite";
82
+
};
83
+
};
64
84
};
65
-
};
66
-
})
67
-
];
68
-
}
85
+
})
86
+
];
87
+
}
+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 {
+15
spindle/db/db.go
+15
spindle/db/db.go
···
45
45
unique(owner, name)
46
46
);
47
47
48
+
create table if not exists spindle_members (
49
+
-- identifiers for the record
50
+
id integer primary key autoincrement,
51
+
did text not null,
52
+
rkey text not null,
53
+
54
+
-- data
55
+
instance text not null,
56
+
subject text not null,
57
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
58
+
59
+
-- constraints
60
+
unique (did, instance, subject)
61
+
);
62
+
48
63
-- status event for a single workflow
49
64
create table if not exists events (
50
65
rkey text not null,
+59
spindle/db/member.go
+59
spindle/db/member.go
···
1
+
package db
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type SpindleMember struct {
10
+
Id int
11
+
Did syntax.DID // owner of the record
12
+
Rkey string // rkey of the record
13
+
Instance string
14
+
Subject syntax.DID // the member being added
15
+
Created time.Time
16
+
}
17
+
18
+
func AddSpindleMember(db *DB, member SpindleMember) error {
19
+
_, err := db.Exec(
20
+
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
21
+
member.Did,
22
+
member.Rkey,
23
+
member.Instance,
24
+
member.Subject,
25
+
)
26
+
return err
27
+
}
28
+
29
+
func RemoveSpindleMember(db *DB, owner_did, rkey string) error {
30
+
_, err := db.Exec(
31
+
"delete from spindle_members where did = ? and rkey = ?",
32
+
owner_did,
33
+
rkey,
34
+
)
35
+
return err
36
+
}
37
+
38
+
func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) {
39
+
query :=
40
+
`select id, did, rkey, instance, subject, created
41
+
from spindle_members
42
+
where did = ? and rkey = ?`
43
+
44
+
var member SpindleMember
45
+
var createdAt string
46
+
err := db.QueryRow(query, did, rkey).Scan(
47
+
&member.Id,
48
+
&member.Did,
49
+
&member.Rkey,
50
+
&member.Instance,
51
+
&member.Subject,
52
+
&createdAt,
53
+
)
54
+
if err != nil {
55
+
return nil, err
56
+
}
57
+
58
+
return &member, nil
59
+
}
+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,
+168
-6
spindle/ingester.go
+168
-6
spindle/ingester.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
8
+
"time"
7
9
8
10
"tangled.sh/tangled.sh/core/api/tangled"
9
11
"tangled.sh/tangled.sh/core/eventconsumer"
12
+
"tangled.sh/tangled.sh/core/idresolver"
13
+
"tangled.sh/tangled.sh/core/rbac"
14
+
"tangled.sh/tangled.sh/core/spindle/db"
10
15
16
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
17
+
"github.com/bluesky-social/indigo/atproto/identity"
18
+
"github.com/bluesky-social/indigo/atproto/syntax"
19
+
"github.com/bluesky-social/indigo/xrpc"
11
20
"github.com/bluesky-social/jetstream/pkg/models"
21
+
securejoin "github.com/cyphar/filepath-securejoin"
12
22
)
13
23
14
24
type Ingester func(ctx context.Context, e *models.Event) error
···
33
43
s.ingestMember(ctx, e)
34
44
case tangled.RepoNSID:
35
45
s.ingestRepo(ctx, e)
46
+
case tangled.RepoCollaboratorNSID:
47
+
s.ingestCollaborator(ctx, e)
36
48
}
37
49
38
50
return err
···
40
52
}
41
53
42
54
func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error {
55
+
var err error
43
56
did := e.Did
44
-
var err error
57
+
rkey := e.Commit.RKey
45
58
46
59
l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID)
47
60
···
56
69
}
57
70
58
71
domain := s.cfg.Server.Hostname
59
-
if s.cfg.Server.Dev {
60
-
domain = s.cfg.Server.ListenAddr
61
-
}
62
72
recordInstance := record.Instance
63
73
64
74
if recordInstance != domain {
···
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 := db.AddSpindleMember(s.db, db.SpindleMember{
86
+
Did: syntax.DID(did),
87
+
Rkey: rkey,
88
+
Instance: recordInstance,
89
+
Subject: syntax.DID(record.Subject),
90
+
Created: time.Now(),
91
+
}); err != nil {
92
+
l.Error("failed to add member", "error", err)
93
+
return fmt.Errorf("failed to add member: %w", err)
94
+
}
95
+
96
+
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
76
97
l.Error("failed to add member", "error", err)
77
98
return fmt.Errorf("failed to add member: %w", err)
78
99
}
···
86
107
87
108
return nil
88
109
110
+
case models.CommitOperationDelete:
111
+
record, err := db.GetSpindleMember(s.db, did, rkey)
112
+
if err != nil {
113
+
l.Error("failed to find member", "error", err)
114
+
return fmt.Errorf("failed to find member: %w", err)
115
+
}
116
+
117
+
if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil {
118
+
l.Error("failed to remove member", "error", err)
119
+
return fmt.Errorf("failed to remove member: %w", err)
120
+
}
121
+
122
+
if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil {
123
+
l.Error("failed to add member", "error", err)
124
+
return fmt.Errorf("failed to add member: %w", err)
125
+
}
126
+
l.Info("added member from firehose", "member", record.Subject)
127
+
128
+
if err := s.db.RemoveDid(record.Subject.String()); err != nil {
129
+
l.Error("failed to add did", "error", err)
130
+
return fmt.Errorf("failed to add did: %w", err)
131
+
}
132
+
s.jc.RemoveDid(record.Subject.String())
133
+
89
134
}
90
135
return nil
91
136
}
92
137
93
-
func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error {
138
+
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
94
139
var err error
140
+
did := e.Did
141
+
resolver := idresolver.DefaultResolver()
95
142
96
143
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
97
144
···
127
174
return fmt.Errorf("failed to add repo: %w", err)
128
175
}
129
176
177
+
didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name)
178
+
if err != nil {
179
+
return err
180
+
}
181
+
182
+
// add repo to rbac
183
+
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil {
184
+
l.Error("failed to add repo to enforcer", "error", err)
185
+
return fmt.Errorf("failed to add repo: %w", err)
186
+
}
187
+
188
+
// add collaborators to rbac
189
+
owner, err := resolver.ResolveIdent(ctx, did)
190
+
if err != nil || owner.Handle.IsInvalidHandle() {
191
+
return err
192
+
}
193
+
if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil {
194
+
return err
195
+
}
196
+
130
197
// add this knot to the event consumer
131
198
src := eventconsumer.NewKnotSource(record.Knot)
132
199
s.ks.AddSource(context.Background(), src)
···
136
203
}
137
204
return nil
138
205
}
206
+
207
+
func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error {
208
+
var err error
209
+
210
+
l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did)
211
+
212
+
l.Info("ingesting collaborator record")
213
+
214
+
switch e.Commit.Operation {
215
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
216
+
raw := e.Commit.Record
217
+
record := tangled.RepoCollaborator{}
218
+
err = json.Unmarshal(raw, &record)
219
+
if err != nil {
220
+
l.Error("invalid record", "error", err)
221
+
return err
222
+
}
223
+
224
+
resolver := idresolver.DefaultResolver()
225
+
226
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
227
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
228
+
return err
229
+
}
230
+
231
+
repoAt, err := syntax.ParseATURI(record.Repo)
232
+
if err != nil {
233
+
l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo)
234
+
return nil
235
+
}
236
+
237
+
// TODO: get rid of this entirely
238
+
// resolve this aturi to extract the repo record
239
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
240
+
if err != nil || owner.Handle.IsInvalidHandle() {
241
+
return fmt.Errorf("failed to resolve handle: %w", err)
242
+
}
243
+
244
+
xrpcc := xrpc.Client{
245
+
Host: owner.PDSEndpoint(),
246
+
}
247
+
248
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
249
+
if err != nil {
250
+
return err
251
+
}
252
+
253
+
repo := resp.Value.Val.(*tangled.Repo)
254
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
255
+
256
+
// check perms for this user
257
+
if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil {
258
+
return fmt.Errorf("insufficient permissions: %w", err)
259
+
}
260
+
261
+
// add collaborator to rbac
262
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
263
+
l.Error("failed to add repo to enforcer", "error", err)
264
+
return fmt.Errorf("failed to add repo: %w", err)
265
+
}
266
+
267
+
return nil
268
+
}
269
+
return nil
270
+
}
271
+
272
+
func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error {
273
+
l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators")
274
+
275
+
l.Info("fetching and adding existing collaborators")
276
+
277
+
xrpcc := xrpc.Client{
278
+
Host: owner.PDSEndpoint(),
279
+
}
280
+
281
+
resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false)
282
+
if err != nil {
283
+
return err
284
+
}
285
+
286
+
var errs error
287
+
for _, r := range resp.Records {
288
+
if r == nil {
289
+
continue
290
+
}
291
+
record := r.Value.Val.(*tangled.RepoCollaborator)
292
+
293
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
294
+
l.Error("failed to add repo to enforcer", "error", err)
295
+
errors.Join(errs, fmt.Errorf("failed to add repo: %w", err))
296
+
}
297
+
}
298
+
299
+
return errs
300
+
}
+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).DeleteMetadata(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
+
}
+95
-43
spindle/server.go
+95
-43
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
}
66
100
67
-
jq := queue.NewQueue(100, 2)
101
+
jq := queue.NewQueue(100, 5)
68
102
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
+
// Check if the spindle knows about any Dids;
115
+
dids, err := d.GetAllDids()
116
+
if err != nil {
117
+
return fmt.Errorf("failed to get all dids: %w", err)
118
+
}
119
+
for _, d := range dids {
120
+
jc.AddDid(d)
121
+
}
122
+
123
+
resolver := idresolver.DefaultResolver()
124
+
79
125
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,
126
+
jc: jc,
127
+
e: e,
128
+
db: d,
129
+
l: logger,
130
+
n: &n,
131
+
eng: eng,
132
+
jq: jq,
133
+
cfg: cfg,
134
+
res: resolver,
135
+
vault: vault,
88
136
}
89
137
90
138
err = e.AddSpindle(rbacDomain)
···
100
148
// starts a job queue runner in the background
101
149
jq.Start()
102
150
defer jq.Stop()
151
+
152
+
// Stop vault token renewal if it implements Stopper
153
+
if stopper, ok := vault.(secrets.Stopper); ok {
154
+
defer stopper.Stop()
155
+
}
103
156
104
157
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
105
158
if err != nil {
···
144
197
mux := chi.NewRouter()
145
198
146
199
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`))
200
+
w.Write(motd)
171
201
})
172
202
mux.HandleFunc("/events", s.Events)
173
203
mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) {
174
204
w.Write([]byte(s.cfg.Server.Owner))
175
205
})
176
206
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
207
+
208
+
mux.Mount("/xrpc", s.XrpcRouter())
177
209
return mux
178
210
}
179
211
212
+
func (s *Spindle) XrpcRouter() http.Handler {
213
+
logger := s.l.With("route", "xrpc")
214
+
215
+
x := xrpc.Xrpc{
216
+
Logger: logger,
217
+
Db: s.db,
218
+
Enforcer: s.e,
219
+
Engine: s.eng,
220
+
Config: s.cfg,
221
+
Resolver: s.res,
222
+
Vault: s.vault,
223
+
}
224
+
225
+
return x.Router()
226
+
}
227
+
180
228
func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
181
229
if msg.Nsid == tangled.PipelineNSID {
182
230
tpl := tangled.Pipeline{}
···
192
240
193
241
if tpl.TriggerMetadata.Repo == nil {
194
242
return fmt.Errorf("no repo data found")
243
+
}
244
+
245
+
if src.Key() != tpl.TriggerMetadata.Repo.Knot {
246
+
return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot)
195
247
}
196
248
197
249
// filter by repos
+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
+
}
+6
tailwind.config.js
+6
tailwind.config.js
···
40
40
color: colors.black,
41
41
"@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {},
42
42
},
43
+
li: {
44
+
"@apply inline-block w-full my-0 py-0": {},
45
+
},
46
+
"ul, ol": {
47
+
"@apply my-1 py-0": {},
48
+
},
43
49
code: {
44
50
"@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
45
51
},