+2
-252
api/tangled/cbor_gen.go
+2
-252
api/tangled/cbor_gen.go
···
2141
2141
2142
2142
return nil
2143
2143
}
2144
-
func (t *Knot) MarshalCBOR(w io.Writer) error {
2145
-
if t == nil {
2146
-
_, err := w.Write(cbg.CborNull)
2147
-
return err
2148
-
}
2149
-
2150
-
cw := cbg.NewCborWriter(w)
2151
-
2152
-
if _, err := cw.Write([]byte{162}); err != nil {
2153
-
return err
2154
-
}
2155
-
2156
-
// t.LexiconTypeID (string) (string)
2157
-
if len("$type") > 1000000 {
2158
-
return xerrors.Errorf("Value in field \"$type\" was too long")
2159
-
}
2160
-
2161
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
2162
-
return err
2163
-
}
2164
-
if _, err := cw.WriteString(string("$type")); err != nil {
2165
-
return err
2166
-
}
2167
-
2168
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.knot"))); err != nil {
2169
-
return err
2170
-
}
2171
-
if _, err := cw.WriteString(string("sh.tangled.knot")); err != nil {
2172
-
return err
2173
-
}
2174
-
2175
-
// t.CreatedAt (string) (string)
2176
-
if len("createdAt") > 1000000 {
2177
-
return xerrors.Errorf("Value in field \"createdAt\" was too long")
2178
-
}
2179
-
2180
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
2181
-
return err
2182
-
}
2183
-
if _, err := cw.WriteString(string("createdAt")); err != nil {
2184
-
return err
2185
-
}
2186
-
2187
-
if len(t.CreatedAt) > 1000000 {
2188
-
return xerrors.Errorf("Value in field t.CreatedAt was too long")
2189
-
}
2190
-
2191
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
2192
-
return err
2193
-
}
2194
-
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
2195
-
return err
2196
-
}
2197
-
return nil
2198
-
}
2199
-
2200
-
func (t *Knot) UnmarshalCBOR(r io.Reader) (err error) {
2201
-
*t = Knot{}
2202
-
2203
-
cr := cbg.NewCborReader(r)
2204
-
2205
-
maj, extra, err := cr.ReadHeader()
2206
-
if err != nil {
2207
-
return err
2208
-
}
2209
-
defer func() {
2210
-
if err == io.EOF {
2211
-
err = io.ErrUnexpectedEOF
2212
-
}
2213
-
}()
2214
-
2215
-
if maj != cbg.MajMap {
2216
-
return fmt.Errorf("cbor input should be of type map")
2217
-
}
2218
-
2219
-
if extra > cbg.MaxLength {
2220
-
return fmt.Errorf("Knot: map struct too large (%d)", extra)
2221
-
}
2222
-
2223
-
n := extra
2224
-
2225
-
nameBuf := make([]byte, 9)
2226
-
for i := uint64(0); i < n; i++ {
2227
-
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
2228
-
if err != nil {
2229
-
return err
2230
-
}
2231
-
2232
-
if !ok {
2233
-
// Field doesn't exist on this type, so ignore it
2234
-
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
2235
-
return err
2236
-
}
2237
-
continue
2238
-
}
2239
-
2240
-
switch string(nameBuf[:nameLen]) {
2241
-
// t.LexiconTypeID (string) (string)
2242
-
case "$type":
2243
-
2244
-
{
2245
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2246
-
if err != nil {
2247
-
return err
2248
-
}
2249
-
2250
-
t.LexiconTypeID = string(sval)
2251
-
}
2252
-
// t.CreatedAt (string) (string)
2253
-
case "createdAt":
2254
-
2255
-
{
2256
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2257
-
if err != nil {
2258
-
return err
2259
-
}
2260
-
2261
-
t.CreatedAt = string(sval)
2262
-
}
2263
-
2264
-
default:
2265
-
// Field doesn't exist on this type, so ignore it
2266
-
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
2267
-
return err
2268
-
}
2269
-
}
2270
-
}
2271
-
2272
-
return nil
2273
-
}
2274
2144
func (t *KnotMember) MarshalCBOR(w io.Writer) error {
2275
2145
if t == nil {
2276
2146
_, err := w.Write(cbg.CborNull)
···
5642
5512
}
5643
5513
5644
5514
cw := cbg.NewCborWriter(w)
5645
-
fieldCount := 7
5515
+
fieldCount := 6
5646
5516
5647
5517
if t.Body == nil {
5648
5518
fieldCount--
···
5772
5642
return err
5773
5643
}
5774
5644
5775
-
// t.IssueId (int64) (int64)
5776
-
if len("issueId") > 1000000 {
5777
-
return xerrors.Errorf("Value in field \"issueId\" was too long")
5778
-
}
5779
-
5780
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil {
5781
-
return err
5782
-
}
5783
-
if _, err := cw.WriteString(string("issueId")); err != nil {
5784
-
return err
5785
-
}
5786
-
5787
-
if t.IssueId >= 0 {
5788
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil {
5789
-
return err
5790
-
}
5791
-
} else {
5792
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil {
5793
-
return err
5794
-
}
5795
-
}
5796
-
5797
5645
// t.CreatedAt (string) (string)
5798
5646
if len("createdAt") > 1000000 {
5799
5647
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
5925
5773
5926
5774
t.Title = string(sval)
5927
5775
}
5928
-
// t.IssueId (int64) (int64)
5929
-
case "issueId":
5930
-
{
5931
-
maj, extra, err := cr.ReadHeader()
5932
-
if err != nil {
5933
-
return err
5934
-
}
5935
-
var extraI int64
5936
-
switch maj {
5937
-
case cbg.MajUnsignedInt:
5938
-
extraI = int64(extra)
5939
-
if extraI < 0 {
5940
-
return fmt.Errorf("int64 positive overflow")
5941
-
}
5942
-
case cbg.MajNegativeInt:
5943
-
extraI = int64(extra)
5944
-
if extraI < 0 {
5945
-
return fmt.Errorf("int64 negative overflow")
5946
-
}
5947
-
extraI = -1 - extraI
5948
-
default:
5949
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
5950
-
}
5951
-
5952
-
t.IssueId = int64(extraI)
5953
-
}
5954
5776
// t.CreatedAt (string) (string)
5955
5777
case "createdAt":
5956
5778
···
5980
5802
}
5981
5803
5982
5804
cw := cbg.NewCborWriter(w)
5983
-
fieldCount := 7
5984
-
5985
-
if t.CommentId == nil {
5986
-
fieldCount--
5987
-
}
5805
+
fieldCount := 6
5988
5806
5989
5807
if t.Owner == nil {
5990
5808
fieldCount--
···
6127
5945
}
6128
5946
}
6129
5947
6130
-
// t.CommentId (int64) (int64)
6131
-
if t.CommentId != nil {
6132
-
6133
-
if len("commentId") > 1000000 {
6134
-
return xerrors.Errorf("Value in field \"commentId\" was too long")
6135
-
}
6136
-
6137
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil {
6138
-
return err
6139
-
}
6140
-
if _, err := cw.WriteString(string("commentId")); err != nil {
6141
-
return err
6142
-
}
6143
-
6144
-
if t.CommentId == nil {
6145
-
if _, err := cw.Write(cbg.CborNull); err != nil {
6146
-
return err
6147
-
}
6148
-
} else {
6149
-
if *t.CommentId >= 0 {
6150
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil {
6151
-
return err
6152
-
}
6153
-
} else {
6154
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil {
6155
-
return err
6156
-
}
6157
-
}
6158
-
}
6159
-
6160
-
}
6161
-
6162
5948
// t.CreatedAt (string) (string)
6163
5949
if len("createdAt") > 1000000 {
6164
5950
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6298
6084
}
6299
6085
6300
6086
t.Owner = (*string)(&sval)
6301
-
}
6302
-
}
6303
-
// t.CommentId (int64) (int64)
6304
-
case "commentId":
6305
-
{
6306
-
6307
-
b, err := cr.ReadByte()
6308
-
if err != nil {
6309
-
return err
6310
-
}
6311
-
if b != cbg.CborNull[0] {
6312
-
if err := cr.UnreadByte(); err != nil {
6313
-
return err
6314
-
}
6315
-
maj, extra, err := cr.ReadHeader()
6316
-
if err != nil {
6317
-
return err
6318
-
}
6319
-
var extraI int64
6320
-
switch maj {
6321
-
case cbg.MajUnsignedInt:
6322
-
extraI = int64(extra)
6323
-
if extraI < 0 {
6324
-
return fmt.Errorf("int64 positive overflow")
6325
-
}
6326
-
case cbg.MajNegativeInt:
6327
-
extraI = int64(extra)
6328
-
if extraI < 0 {
6329
-
return fmt.Errorf("int64 negative overflow")
6330
-
}
6331
-
extraI = -1 - extraI
6332
-
default:
6333
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
6334
-
}
6335
-
6336
-
t.CommentId = (*int64)(&extraI)
6337
6087
}
6338
6088
}
6339
6089
// t.CreatedAt (string) (string)
-1
api/tangled/issuecomment.go
-1
api/tangled/issuecomment.go
···
19
19
type RepoIssueComment struct {
20
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"`
21
21
Body string `json:"body" cborgen:"body"`
22
-
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
23
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
24
23
Issue string `json:"issue" cborgen:"issue"`
25
24
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
-34
api/tangled/repocreate.go
-34
api/tangled/repocreate.go
···
1
-
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
-
3
-
package tangled
4
-
5
-
// schema: sh.tangled.repo.create
6
-
7
-
import (
8
-
"context"
9
-
10
-
"github.com/bluesky-social/indigo/lex/util"
11
-
)
12
-
13
-
const (
14
-
RepoCreateNSID = "sh.tangled.repo.create"
15
-
)
16
-
17
-
// RepoCreate_Input is the input argument to a sh.tangled.repo.create call.
18
-
type RepoCreate_Input struct {
19
-
// defaultBranch: Default branch to push to
20
-
DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"`
21
-
// rkey: Rkey of the repository record
22
-
Rkey string `json:"rkey" cborgen:"rkey"`
23
-
// source: A source URL to clone from, populate this when forking or importing a repository.
24
-
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
25
-
}
26
-
27
-
// RepoCreate calls the XRPC method "sh.tangled.repo.create".
28
-
func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) error {
29
-
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, nil); err != nil {
30
-
return err
31
-
}
32
-
33
-
return nil
34
-
}
-34
api/tangled/repodelete.go
-34
api/tangled/repodelete.go
···
1
-
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
-
3
-
package tangled
4
-
5
-
// schema: sh.tangled.repo.delete
6
-
7
-
import (
8
-
"context"
9
-
10
-
"github.com/bluesky-social/indigo/lex/util"
11
-
)
12
-
13
-
const (
14
-
RepoDeleteNSID = "sh.tangled.repo.delete"
15
-
)
16
-
17
-
// RepoDelete_Input is the input argument to a sh.tangled.repo.delete call.
18
-
type RepoDelete_Input struct {
19
-
// did: DID of the repository owner
20
-
Did string `json:"did" cborgen:"did"`
21
-
// name: Name of the repository to delete
22
-
Name string `json:"name" cborgen:"name"`
23
-
// rkey: Rkey of the repository record
24
-
Rkey string `json:"rkey" cborgen:"rkey"`
25
-
}
26
-
27
-
// RepoDelete calls the XRPC method "sh.tangled.repo.delete".
28
-
func RepoDelete(ctx context.Context, c util.LexClient, input *RepoDelete_Input) error {
29
-
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.delete", nil, input, nil); err != nil {
30
-
return err
31
-
}
32
-
33
-
return nil
34
-
}
-45
api/tangled/repoforkStatus.go
-45
api/tangled/repoforkStatus.go
···
1
-
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
-
3
-
package tangled
4
-
5
-
// schema: sh.tangled.repo.forkStatus
6
-
7
-
import (
8
-
"context"
9
-
10
-
"github.com/bluesky-social/indigo/lex/util"
11
-
)
12
-
13
-
const (
14
-
RepoForkStatusNSID = "sh.tangled.repo.forkStatus"
15
-
)
16
-
17
-
// RepoForkStatus_Input is the input argument to a sh.tangled.repo.forkStatus call.
18
-
type RepoForkStatus_Input struct {
19
-
// branch: Branch to check status for
20
-
Branch string `json:"branch" cborgen:"branch"`
21
-
// did: DID of the fork owner
22
-
Did string `json:"did" cborgen:"did"`
23
-
// hiddenRef: Hidden ref to use for comparison
24
-
HiddenRef string `json:"hiddenRef" cborgen:"hiddenRef"`
25
-
// name: Name of the forked repository
26
-
Name string `json:"name" cborgen:"name"`
27
-
// source: Source repository URL
28
-
Source string `json:"source" cborgen:"source"`
29
-
}
30
-
31
-
// RepoForkStatus_Output is the output of a sh.tangled.repo.forkStatus call.
32
-
type RepoForkStatus_Output struct {
33
-
// status: Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch
34
-
Status int64 `json:"status" cborgen:"status"`
35
-
}
36
-
37
-
// RepoForkStatus calls the XRPC method "sh.tangled.repo.forkStatus".
38
-
func RepoForkStatus(ctx context.Context, c util.LexClient, input *RepoForkStatus_Input) (*RepoForkStatus_Output, error) {
39
-
var out RepoForkStatus_Output
40
-
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkStatus", nil, input, &out); err != nil {
41
-
return nil, err
42
-
}
43
-
44
-
return &out, nil
45
-
}
-36
api/tangled/repoforkSync.go
-36
api/tangled/repoforkSync.go
···
1
-
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
-
3
-
package tangled
4
-
5
-
// schema: sh.tangled.repo.forkSync
6
-
7
-
import (
8
-
"context"
9
-
10
-
"github.com/bluesky-social/indigo/lex/util"
11
-
)
12
-
13
-
const (
14
-
RepoForkSyncNSID = "sh.tangled.repo.forkSync"
15
-
)
16
-
17
-
// RepoForkSync_Input is the input argument to a sh.tangled.repo.forkSync call.
18
-
type RepoForkSync_Input struct {
19
-
// branch: Branch to sync
20
-
Branch string `json:"branch" cborgen:"branch"`
21
-
// did: DID of the fork owner
22
-
Did string `json:"did" cborgen:"did"`
23
-
// name: Name of the forked repository
24
-
Name string `json:"name" cborgen:"name"`
25
-
// source: AT-URI of the source repository
26
-
Source string `json:"source" cborgen:"source"`
27
-
}
28
-
29
-
// RepoForkSync calls the XRPC method "sh.tangled.repo.forkSync".
30
-
func RepoForkSync(ctx context.Context, c util.LexClient, input *RepoForkSync_Input) error {
31
-
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkSync", nil, input, nil); err != nil {
32
-
return err
33
-
}
34
-
35
-
return nil
36
-
}
-1
api/tangled/repoissue.go
-1
api/tangled/repoissue.go
···
20
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
21
21
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
IssueId int64 `json:"issueId" cborgen:"issueId"`
24
23
Owner string `json:"owner" cborgen:"owner"`
25
24
Repo string `json:"repo" cborgen:"repo"`
26
25
Title string `json:"title" cborgen:"title"`
-44
api/tangled/repomerge.go
-44
api/tangled/repomerge.go
···
1
-
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
-
3
-
package tangled
4
-
5
-
// schema: sh.tangled.repo.merge
6
-
7
-
import (
8
-
"context"
9
-
10
-
"github.com/bluesky-social/indigo/lex/util"
11
-
)
12
-
13
-
const (
14
-
RepoMergeNSID = "sh.tangled.repo.merge"
15
-
)
16
-
17
-
// RepoMerge_Input is the input argument to a sh.tangled.repo.merge call.
18
-
type RepoMerge_Input struct {
19
-
// authorEmail: Author email for the merge commit
20
-
AuthorEmail *string `json:"authorEmail,omitempty" cborgen:"authorEmail,omitempty"`
21
-
// authorName: Author name for the merge commit
22
-
AuthorName *string `json:"authorName,omitempty" cborgen:"authorName,omitempty"`
23
-
// branch: Target branch to merge into
24
-
Branch string `json:"branch" cborgen:"branch"`
25
-
// commitBody: Additional commit message body
26
-
CommitBody *string `json:"commitBody,omitempty" cborgen:"commitBody,omitempty"`
27
-
// commitMessage: Merge commit message
28
-
CommitMessage *string `json:"commitMessage,omitempty" cborgen:"commitMessage,omitempty"`
29
-
// did: DID of the repository owner
30
-
Did string `json:"did" cborgen:"did"`
31
-
// name: Name of the repository
32
-
Name string `json:"name" cborgen:"name"`
33
-
// patch: Patch content to merge
34
-
Patch string `json:"patch" cborgen:"patch"`
35
-
}
36
-
37
-
// RepoMerge calls the XRPC method "sh.tangled.repo.merge".
38
-
func RepoMerge(ctx context.Context, c util.LexClient, input *RepoMerge_Input) error {
39
-
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.merge", nil, input, nil); err != nil {
40
-
return err
41
-
}
42
-
43
-
return nil
44
-
}
-57
api/tangled/repomergeCheck.go
-57
api/tangled/repomergeCheck.go
···
1
-
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
-
3
-
package tangled
4
-
5
-
// schema: sh.tangled.repo.mergeCheck
6
-
7
-
import (
8
-
"context"
9
-
10
-
"github.com/bluesky-social/indigo/lex/util"
11
-
)
12
-
13
-
const (
14
-
RepoMergeCheckNSID = "sh.tangled.repo.mergeCheck"
15
-
)
16
-
17
-
// RepoMergeCheck_ConflictInfo is a "conflictInfo" in the sh.tangled.repo.mergeCheck schema.
18
-
type RepoMergeCheck_ConflictInfo struct {
19
-
// filename: Name of the conflicted file
20
-
Filename string `json:"filename" cborgen:"filename"`
21
-
// reason: Reason for the conflict
22
-
Reason string `json:"reason" cborgen:"reason"`
23
-
}
24
-
25
-
// RepoMergeCheck_Input is the input argument to a sh.tangled.repo.mergeCheck call.
26
-
type RepoMergeCheck_Input struct {
27
-
// branch: Target branch to merge into
28
-
Branch string `json:"branch" cborgen:"branch"`
29
-
// did: DID of the repository owner
30
-
Did string `json:"did" cborgen:"did"`
31
-
// name: Name of the repository
32
-
Name string `json:"name" cborgen:"name"`
33
-
// patch: Patch or pull request to check for merge conflicts
34
-
Patch string `json:"patch" cborgen:"patch"`
35
-
}
36
-
37
-
// RepoMergeCheck_Output is the output of a sh.tangled.repo.mergeCheck call.
38
-
type RepoMergeCheck_Output struct {
39
-
// conflicts: List of files with merge conflicts
40
-
Conflicts []*RepoMergeCheck_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"`
41
-
// error: Error message if check failed
42
-
Error *string `json:"error,omitempty" cborgen:"error,omitempty"`
43
-
// is_conflicted: Whether the merge has conflicts
44
-
Is_conflicted bool `json:"is_conflicted" cborgen:"is_conflicted"`
45
-
// message: Additional message about the merge check
46
-
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
47
-
}
48
-
49
-
// RepoMergeCheck calls the XRPC method "sh.tangled.repo.mergeCheck".
50
-
func RepoMergeCheck(ctx context.Context, c util.LexClient, input *RepoMergeCheck_Input) (*RepoMergeCheck_Output, error) {
51
-
var out RepoMergeCheck_Output
52
-
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.mergeCheck", nil, input, &out); err != nil {
53
-
return nil, err
54
-
}
55
-
56
-
return &out, nil
57
-
}
-22
api/tangled/tangledknot.go
-22
api/tangled/tangledknot.go
···
1
-
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
-
3
-
package tangled
4
-
5
-
// schema: sh.tangled.knot
6
-
7
-
import (
8
-
"github.com/bluesky-social/indigo/lex/util"
9
-
)
10
-
11
-
const (
12
-
KnotNSID = "sh.tangled.knot"
13
-
)
14
-
15
-
func init() {
16
-
util.RegisterType("sh.tangled.knot", &Knot{})
17
-
} //
18
-
// RECORDTYPE: Knot
19
-
type Knot struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.knot" cborgen:"$type,const=sh.tangled.knot"`
21
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
-
}
+1
-1
appview/config/config.go
+1
-1
appview/config/config.go
-25
appview/db/db.go
-25
appview/db/db.go
···
612
612
return nil
613
613
})
614
614
615
-
// drop all knot secrets, add unique constraint to knots
616
-
//
617
-
// knots will henceforth use service auth for signed requests
618
-
runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error {
619
-
_, err := tx.Exec(`
620
-
create table registrations_new (
621
-
id integer primary key autoincrement,
622
-
domain text not null,
623
-
did text not null,
624
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
625
-
registered text,
626
-
read_only integer not null default 0,
627
-
unique(domain, did)
628
-
);
629
-
630
-
insert into registrations_new (id, domain, did, created, registered, read_only)
631
-
select id, domain, did, created, registered, 1 from registrations
632
-
where registered is not null;
633
-
634
-
drop table registrations;
635
-
alter table registrations_new rename to registrations;
636
-
`)
637
-
return err
638
-
})
639
-
640
615
// recreate and add rkey + created columns with default constraint
641
616
runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error {
642
617
// create new table
+41
-144
appview/db/follow.go
+41
-144
appview/db/follow.go
···
1
1
package db
2
2
3
3
import (
4
-
"fmt"
5
4
"log"
6
-
"strings"
7
5
"time"
8
6
)
9
7
···
55
53
return err
56
54
}
57
55
58
-
type FollowStats struct {
59
-
Followers int
60
-
Following int
61
-
}
62
-
63
-
func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
56
+
func GetFollowerFollowingCount(e Execer, did string) (int, int, error) {
64
57
followers, following := 0, 0
65
58
err := e.QueryRow(
66
-
`SELECT
59
+
`SELECT
67
60
COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers,
68
61
COUNT(CASE WHEN user_did = ? THEN 1 END) AS following
69
62
FROM follows;`, did, did).Scan(&followers, &following)
70
63
if err != nil {
71
-
return FollowStats{}, err
64
+
return 0, 0, err
72
65
}
73
-
return FollowStats{
74
-
Followers: followers,
75
-
Following: following,
76
-
}, nil
66
+
return followers, following, nil
77
67
}
78
68
79
-
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) {
80
-
if len(dids) == 0 {
81
-
return nil, nil
82
-
}
69
+
type FollowStatus int
83
70
84
-
placeholders := make([]string, len(dids))
85
-
for i := range placeholders {
86
-
placeholders[i] = "?"
87
-
}
88
-
placeholderStr := strings.Join(placeholders, ",")
71
+
const (
72
+
IsNotFollowing FollowStatus = iota
73
+
IsFollowing
74
+
IsSelf
75
+
)
89
76
90
-
args := make([]any, len(dids)*2)
91
-
for i, did := range dids {
92
-
args[i] = did
93
-
args[i+len(dids)] = did
77
+
func (s FollowStatus) String() string {
78
+
switch s {
79
+
case IsNotFollowing:
80
+
return "IsNotFollowing"
81
+
case IsFollowing:
82
+
return "IsFollowing"
83
+
case IsSelf:
84
+
return "IsSelf"
85
+
default:
86
+
return "IsNotFollowing"
94
87
}
95
-
96
-
query := fmt.Sprintf(`
97
-
select
98
-
coalesce(f.did, g.did) as did,
99
-
coalesce(f.followers, 0) as followers,
100
-
coalesce(g.following, 0) as following
101
-
from (
102
-
select subject_did as did, count(*) as followers
103
-
from follows
104
-
where subject_did in (%s)
105
-
group by subject_did
106
-
) f
107
-
full outer join (
108
-
select user_did as did, count(*) as following
109
-
from follows
110
-
where user_did in (%s)
111
-
group by user_did
112
-
) g on f.did = g.did`,
113
-
placeholderStr, placeholderStr)
114
-
115
-
result := make(map[string]FollowStats)
116
-
117
-
rows, err := e.Query(query, args...)
118
-
if err != nil {
119
-
return nil, err
120
-
}
121
-
defer rows.Close()
88
+
}
122
89
123
-
for rows.Next() {
124
-
var did string
125
-
var followers, following int
126
-
if err := rows.Scan(&did, &followers, &following); err != nil {
127
-
return nil, err
128
-
}
129
-
result[did] = FollowStats{
130
-
Followers: followers,
131
-
Following: following,
132
-
}
90
+
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
91
+
if userDid == subjectDid {
92
+
return IsSelf
93
+
} else if _, err := GetFollow(e, userDid, subjectDid); err != nil {
94
+
return IsNotFollowing
95
+
} else {
96
+
return IsFollowing
133
97
}
134
-
135
-
for _, did := range dids {
136
-
if _, exists := result[did]; !exists {
137
-
result[did] = FollowStats{
138
-
Followers: 0,
139
-
Following: 0,
140
-
}
141
-
}
142
-
}
143
-
144
-
return result, nil
145
98
}
146
99
147
-
func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) {
100
+
func GetAllFollows(e Execer, limit int) ([]Follow, error) {
148
101
var follows []Follow
149
102
150
-
var conditions []string
151
-
var args []any
152
-
for _, filter := range filters {
153
-
conditions = append(conditions, filter.Condition())
154
-
args = append(args, filter.Arg()...)
155
-
}
156
-
157
-
whereClause := ""
158
-
if conditions != nil {
159
-
whereClause = " where " + strings.Join(conditions, " and ")
160
-
}
161
-
limitClause := ""
162
-
if limit > 0 {
163
-
limitClause = " limit ?"
164
-
args = append(args, limit)
165
-
}
166
-
167
-
query := fmt.Sprintf(
168
-
`select user_did, subject_did, followed_at, rkey
103
+
rows, err := e.Query(`
104
+
select user_did, subject_did, followed_at, rkey
169
105
from follows
170
-
%s
171
106
order by followed_at desc
172
-
%s
173
-
`, whereClause, limitClause)
174
-
175
-
rows, err := e.Query(query, args...)
107
+
limit ?`, limit,
108
+
)
176
109
if err != nil {
177
110
return nil, err
178
111
}
112
+
defer rows.Close()
113
+
179
114
for rows.Next() {
180
115
var follow Follow
181
116
var followedAt string
182
-
err := rows.Scan(
183
-
&follow.UserDid,
184
-
&follow.SubjectDid,
185
-
&followedAt,
186
-
&follow.Rkey,
187
-
)
188
-
if err != nil {
117
+
if err := rows.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey); err != nil {
189
118
return nil, err
190
119
}
120
+
191
121
followedAtTime, err := time.Parse(time.RFC3339, followedAt)
192
122
if err != nil {
193
123
log.Println("unable to determine followed at time")
···
195
125
} else {
196
126
follow.FollowedAt = followedAtTime
197
127
}
128
+
198
129
follows = append(follows, follow)
199
130
}
200
-
return follows, nil
201
-
}
202
-
203
-
func GetFollowers(e Execer, did string) ([]Follow, error) {
204
-
return GetFollows(e, 0, FilterEq("subject_did", did))
205
-
}
206
131
207
-
func GetFollowing(e Execer, did string) ([]Follow, error) {
208
-
return GetFollows(e, 0, FilterEq("user_did", did))
209
-
}
210
-
211
-
type FollowStatus int
212
-
213
-
const (
214
-
IsNotFollowing FollowStatus = iota
215
-
IsFollowing
216
-
IsSelf
217
-
)
218
-
219
-
func (s FollowStatus) String() string {
220
-
switch s {
221
-
case IsNotFollowing:
222
-
return "IsNotFollowing"
223
-
case IsFollowing:
224
-
return "IsFollowing"
225
-
case IsSelf:
226
-
return "IsSelf"
227
-
default:
228
-
return "IsNotFollowing"
132
+
if err := rows.Err(); err != nil {
133
+
return nil, err
229
134
}
230
-
}
231
135
232
-
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
233
-
if userDid == subjectDid {
234
-
return IsSelf
235
-
} else if _, err := GetFollow(e, userDid, subjectDid); err != nil {
236
-
return IsNotFollowing
237
-
} else {
238
-
return IsFollowing
239
-
}
136
+
return follows, nil
240
137
}
+105
appview/db/issues.go
+105
appview/db/issues.go
···
3
3
import (
4
4
"database/sql"
5
5
"fmt"
6
+
mathrand "math/rand/v2"
6
7
"strings"
7
8
"time"
8
9
···
47
48
48
49
func (i *Issue) AtUri() syntax.ATURI {
49
50
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey))
51
+
}
52
+
53
+
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
54
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
55
+
if err != nil {
56
+
created = time.Now()
57
+
}
58
+
59
+
body := ""
60
+
if record.Body != nil {
61
+
body = *record.Body
62
+
}
63
+
64
+
return Issue{
65
+
RepoAt: syntax.ATURI(record.Repo),
66
+
OwnerDid: record.Owner,
67
+
Rkey: rkey,
68
+
Created: created,
69
+
Title: record.Title,
70
+
Body: body,
71
+
Open: true, // new issues are open by default
72
+
}
73
+
}
74
+
75
+
func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) {
76
+
ownerDid := issueUri.Authority().String()
77
+
issueRkey := issueUri.RecordKey().String()
78
+
79
+
var repoAt string
80
+
var issueId int
81
+
82
+
query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?`
83
+
err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId)
84
+
if err != nil {
85
+
return "", 0, err
86
+
}
87
+
88
+
return syntax.ATURI(repoAt), issueId, nil
89
+
}
90
+
91
+
func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) {
92
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
93
+
if err != nil {
94
+
created = time.Now()
95
+
}
96
+
97
+
ownerDid := did
98
+
if record.Owner != nil {
99
+
ownerDid = *record.Owner
100
+
}
101
+
102
+
issueUri, err := syntax.ParseATURI(record.Issue)
103
+
if err != nil {
104
+
return Comment{}, err
105
+
}
106
+
107
+
repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri)
108
+
if err != nil {
109
+
return Comment{}, err
110
+
}
111
+
112
+
comment := Comment{
113
+
OwnerDid: ownerDid,
114
+
RepoAt: repoAt,
115
+
Rkey: rkey,
116
+
Body: record.Body,
117
+
Issue: issueId,
118
+
CommentId: mathrand.IntN(1000000),
119
+
Created: &created,
120
+
}
121
+
122
+
return comment, nil
50
123
}
51
124
52
125
func NewIssue(tx *sql.Tx, issue *Issue) error {
···
550
623
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
551
624
where repo_at = ? and issue_id = ? and comment_id = ?
552
625
`, repoAt, issueId, commentId)
626
+
return err
627
+
}
628
+
629
+
func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error {
630
+
_, err := e.Exec(
631
+
`
632
+
update comments
633
+
set body = ?,
634
+
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
635
+
where owner_did = ? and rkey = ?
636
+
`, newBody, ownerDid, rkey)
637
+
return err
638
+
}
639
+
640
+
func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error {
641
+
_, err := e.Exec(
642
+
`
643
+
update comments
644
+
set body = "",
645
+
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
646
+
where owner_did = ? and rkey = ?
647
+
`, ownerDid, rkey)
648
+
return err
649
+
}
650
+
651
+
func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error {
652
+
_, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey)
653
+
return err
654
+
}
655
+
656
+
func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error {
657
+
_, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey)
553
658
return err
554
659
}
555
660
+7
-2
appview/db/profile.go
+7
-2
appview/db/profile.go
···
348
348
return tx.Commit()
349
349
}
350
350
351
-
func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) {
351
+
func GetProfiles(e Execer, filters ...filter) ([]Profile, error) {
352
352
var conditions []string
353
353
var args []any
354
354
for _, filter := range filters {
···
448
448
idxs[did] = idx + 1
449
449
}
450
450
451
-
return profileMap, nil
451
+
var profiles []Profile
452
+
for _, p := range profileMap {
453
+
profiles = append(profiles, *p)
454
+
}
455
+
456
+
return profiles, nil
452
457
}
453
458
454
459
func GetProfile(e Execer, did string) (*Profile, error) {
+125
-89
appview/db/registration.go
+125
-89
appview/db/registration.go
···
1
1
package db
2
2
3
3
import (
4
+
"crypto/rand"
4
5
"database/sql"
6
+
"encoding/hex"
5
7
"fmt"
6
-
"strings"
8
+
"log"
7
9
"time"
8
10
)
9
11
10
-
// Registration represents a knot registration. Knot would've been a better
11
-
// name but we're stuck with this for historical reasons.
12
12
type Registration struct {
13
13
Id int64
14
14
Domain string
15
15
ByDid string
16
16
Created *time.Time
17
17
Registered *time.Time
18
-
ReadOnly bool
19
18
}
20
19
21
20
func (r *Registration) Status() Status {
22
-
if r.ReadOnly {
23
-
return ReadOnly
24
-
} else if r.Registered != nil {
21
+
if r.Registered != nil {
25
22
return Registered
26
23
} else {
27
24
return Pending
28
25
}
29
26
}
30
27
31
-
func (r *Registration) IsRegistered() bool {
32
-
return r.Status() == Registered
33
-
}
34
-
35
-
func (r *Registration) IsReadOnly() bool {
36
-
return r.Status() == ReadOnly
37
-
}
38
-
39
-
func (r *Registration) IsPending() bool {
40
-
return r.Status() == Pending
41
-
}
42
-
43
28
type Status uint32
44
29
45
30
const (
46
31
Registered Status = iota
47
32
Pending
48
-
ReadOnly
49
33
)
50
34
51
-
func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) {
35
+
// returns registered status, did of owner, error
36
+
func RegistrationsByDid(e Execer, did string) ([]Registration, error) {
52
37
var registrations []Registration
53
38
54
-
var conditions []string
55
-
var args []any
56
-
for _, filter := range filters {
57
-
conditions = append(conditions, filter.Condition())
58
-
args = append(args, filter.Arg()...)
59
-
}
60
-
61
-
whereClause := ""
62
-
if conditions != nil {
63
-
whereClause = " where " + strings.Join(conditions, " and ")
64
-
}
65
-
66
-
query := fmt.Sprintf(`
67
-
select id, domain, did, created, registered, read_only
68
-
from registrations
69
-
%s
70
-
order by created
71
-
`,
72
-
whereClause,
73
-
)
74
-
75
-
rows, err := e.Query(query, args...)
39
+
rows, err := e.Query(`
40
+
select id, domain, did, created, registered from registrations
41
+
where did = ?
42
+
`, did)
76
43
if err != nil {
77
44
return nil, err
78
45
}
79
46
80
47
for rows.Next() {
81
-
var createdAt string
82
-
var registeredAt sql.Null[string]
83
-
var readOnly int
84
-
var reg Registration
48
+
var createdAt *string
49
+
var registeredAt *string
50
+
var registration Registration
51
+
err = rows.Scan(®istration.Id, ®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
85
52
86
-
err = rows.Scan(®.Id, ®.Domain, ®.ByDid, &createdAt, ®isteredAt, &readOnly)
87
53
if err != nil {
88
-
return nil, err
54
+
log.Println(err)
55
+
} else {
56
+
createdAtTime, _ := time.Parse(time.RFC3339, *createdAt)
57
+
var registeredAtTime *time.Time
58
+
if registeredAt != nil {
59
+
x, _ := time.Parse(time.RFC3339, *registeredAt)
60
+
registeredAtTime = &x
61
+
}
62
+
63
+
registration.Created = &createdAtTime
64
+
registration.Registered = registeredAtTime
65
+
registrations = append(registrations, registration)
89
66
}
67
+
}
90
68
91
-
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
92
-
reg.Created = &t
93
-
}
69
+
return registrations, nil
70
+
}
71
+
72
+
// returns registered status, did of owner, error
73
+
func RegistrationByDomain(e Execer, domain string) (*Registration, error) {
74
+
var createdAt *string
75
+
var registeredAt *string
76
+
var registration Registration
94
77
95
-
if registeredAt.Valid {
96
-
if t, err := time.Parse(time.RFC3339, registeredAt.V); err == nil {
97
-
reg.Registered = &t
98
-
}
99
-
}
78
+
err := e.QueryRow(`
79
+
select id, domain, did, created, registered from registrations
80
+
where domain = ?
81
+
`, domain).Scan(®istration.Id, ®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
100
82
101
-
if readOnly != 0 {
102
-
reg.ReadOnly = true
83
+
if err != nil {
84
+
if err == sql.ErrNoRows {
85
+
return nil, nil
86
+
} else {
87
+
return nil, err
103
88
}
89
+
}
104
90
105
-
registrations = append(registrations, reg)
91
+
createdAtTime, _ := time.Parse(time.RFC3339, *createdAt)
92
+
var registeredAtTime *time.Time
93
+
if registeredAt != nil {
94
+
x, _ := time.Parse(time.RFC3339, *registeredAt)
95
+
registeredAtTime = &x
106
96
}
107
97
108
-
return registrations, nil
98
+
registration.Created = &createdAtTime
99
+
registration.Registered = registeredAtTime
100
+
101
+
return ®istration, nil
102
+
}
103
+
104
+
func genSecret() string {
105
+
key := make([]byte, 32)
106
+
rand.Read(key)
107
+
return hex.EncodeToString(key)
109
108
}
110
109
111
-
func MarkRegistered(e Execer, filters ...filter) error {
112
-
var conditions []string
113
-
var args []any
114
-
for _, filter := range filters {
115
-
conditions = append(conditions, filter.Condition())
116
-
args = append(args, filter.Arg()...)
110
+
func GenerateRegistrationKey(e Execer, domain, did string) (string, error) {
111
+
// sanity check: does this domain already have a registration?
112
+
reg, err := RegistrationByDomain(e, domain)
113
+
if err != nil {
114
+
return "", err
117
115
}
118
116
119
-
query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0"
120
-
if len(conditions) > 0 {
121
-
query += " where " + strings.Join(conditions, " and ")
117
+
// registration is open
118
+
if reg != nil {
119
+
switch reg.Status() {
120
+
case Registered:
121
+
// already registered by `owner`
122
+
return "", fmt.Errorf("%s already registered by %s", domain, reg.ByDid)
123
+
case Pending:
124
+
// TODO: be loud about this
125
+
log.Printf("%s registered by %s, status pending", domain, reg.ByDid)
126
+
}
127
+
}
128
+
129
+
secret := genSecret()
130
+
131
+
_, err = e.Exec(`
132
+
insert into registrations (domain, did, secret)
133
+
values (?, ?, ?)
134
+
on conflict(domain) do update set did = excluded.did, secret = excluded.secret, created = excluded.created
135
+
`, domain, did, secret)
136
+
137
+
if err != nil {
138
+
return "", err
122
139
}
123
140
124
-
_, err := e.Exec(query, args...)
125
-
return err
141
+
return secret, nil
126
142
}
127
143
128
-
func AddKnot(e Execer, domain, did string) error {
129
-
_, err := e.Exec(`
130
-
insert into registrations (domain, did)
131
-
values (?, ?)
132
-
`, domain, did)
133
-
return err
144
+
func GetRegistrationKey(e Execer, domain string) (string, error) {
145
+
res := e.QueryRow(`select secret from registrations where domain = ?`, domain)
146
+
147
+
var secret string
148
+
err := res.Scan(&secret)
149
+
if err != nil || secret == "" {
150
+
return "", err
151
+
}
152
+
153
+
return secret, nil
134
154
}
135
155
136
-
func DeleteKnot(e Execer, filters ...filter) error {
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()...)
156
+
func GetCompletedRegistrations(e Execer) ([]string, error) {
157
+
rows, err := e.Query(`select domain from registrations where registered not null`)
158
+
if err != nil {
159
+
return nil, err
160
+
}
161
+
162
+
var domains []string
163
+
for rows.Next() {
164
+
var domain string
165
+
err = rows.Scan(&domain)
166
+
167
+
if err != nil {
168
+
log.Println(err)
169
+
} else {
170
+
domains = append(domains, domain)
171
+
}
142
172
}
143
173
144
-
whereClause := ""
145
-
if conditions != nil {
146
-
whereClause = " where " + strings.Join(conditions, " and ")
174
+
if err = rows.Err(); err != nil {
175
+
return nil, err
147
176
}
148
177
149
-
query := fmt.Sprintf(`delete from registrations %s`, whereClause)
178
+
return domains, nil
179
+
}
180
+
181
+
func Register(e Execer, domain string) error {
182
+
_, err := e.Exec(`
183
+
update registrations
184
+
set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
185
+
where domain = ?;
186
+
`, domain)
150
187
151
-
_, err := e.Exec(query, args...)
152
188
return err
153
189
}
+22
-6
appview/db/timeline.go
+22
-6
appview/db/timeline.go
···
20
20
*FollowStats
21
21
}
22
22
23
+
type FollowStats struct {
24
+
Followers int
25
+
Following int
26
+
}
27
+
23
28
const Limit = 50
24
29
25
30
// TODO: this gathers heterogenous events from different sources and aggregates
···
132
137
}
133
138
134
139
func getTimelineFollows(e Execer) ([]TimelineEvent, error) {
135
-
follows, err := GetFollows(e, Limit)
140
+
follows, err := GetAllFollows(e, Limit)
136
141
if err != nil {
137
142
return nil, err
138
143
}
···
146
151
return nil, nil
147
152
}
148
153
154
+
profileMap := make(map[string]Profile)
149
155
profiles, err := GetProfiles(e, FilterIn("did", subjects))
150
156
if err != nil {
151
157
return nil, err
158
+
}
159
+
for _, p := range profiles {
160
+
profileMap[p.Did] = p
152
161
}
153
162
154
-
followStatMap, err := GetFollowerFollowingCounts(e, subjects)
155
-
if err != nil {
156
-
return nil, err
163
+
followStatMap := make(map[string]FollowStats)
164
+
for _, s := range subjects {
165
+
followers, following, err := GetFollowerFollowingCount(e, s)
166
+
if err != nil {
167
+
return nil, err
168
+
}
169
+
followStatMap[s] = FollowStats{
170
+
Followers: followers,
171
+
Following: following,
172
+
}
157
173
}
158
174
159
175
var events []TimelineEvent
160
176
for _, f := range follows {
161
-
profile, _ := profiles[f.SubjectDid]
177
+
profile, _ := profileMap[f.SubjectDid]
162
178
followStatMap, _ := followStatMap[f.SubjectDid]
163
179
164
180
events = append(events, TimelineEvent{
165
181
Follow: &f,
166
-
Profile: profile,
182
+
Profile: &profile,
167
183
FollowStats: &followStatMap,
168
184
EventAt: f.FollowedAt,
169
185
})
+111
-98
appview/ingester.go
+111
-98
appview/ingester.go
···
5
5
"encoding/json"
6
6
"fmt"
7
7
"log/slog"
8
+
"strings"
8
9
"time"
9
10
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
···
14
15
"tangled.sh/tangled.sh/core/api/tangled"
15
16
"tangled.sh/tangled.sh/core/appview/config"
16
17
"tangled.sh/tangled.sh/core/appview/db"
17
-
"tangled.sh/tangled.sh/core/appview/serververify"
18
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
19
+
"tangled.sh/tangled.sh/core/appview/spindleverify"
18
20
"tangled.sh/tangled.sh/core/idresolver"
19
21
"tangled.sh/tangled.sh/core/rbac"
20
22
)
···
61
63
case tangled.ActorProfileNSID:
62
64
err = i.ingestProfile(e)
63
65
case tangled.SpindleMemberNSID:
64
-
err = i.ingestSpindleMember(e)
66
+
err = i.ingestSpindleMember(ctx, e)
65
67
case tangled.SpindleNSID:
66
-
err = i.ingestSpindle(e)
67
-
case tangled.KnotMemberNSID:
68
-
err = i.ingestKnotMember(e)
69
-
case tangled.KnotNSID:
70
-
err = i.ingestKnot(e)
68
+
err = i.ingestSpindle(ctx, e)
71
69
case tangled.StringNSID:
72
70
err = i.ingestString(e)
71
+
case tangled.RepoIssueNSID:
72
+
err = i.ingestIssue(ctx, e)
73
+
case tangled.RepoIssueCommentNSID:
74
+
err = i.ingestIssueComment(e)
73
75
}
74
76
l = i.Logger.With("nsid", e.Commit.Collection)
75
77
}
···
340
342
return nil
341
343
}
342
344
343
-
func (i *Ingester) ingestSpindleMember(e *models.Event) error {
345
+
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error {
344
346
did := e.Did
345
347
var err error
346
348
···
363
365
return fmt.Errorf("failed to enforce permissions: %w", err)
364
366
}
365
367
366
-
memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject)
368
+
memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject)
367
369
if err != nil {
368
370
return err
369
371
}
···
446
448
return nil
447
449
}
448
450
449
-
func (i *Ingester) ingestSpindle(e *models.Event) error {
451
+
func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error {
450
452
did := e.Did
451
453
var err error
452
454
···
479
481
return err
480
482
}
481
483
482
-
err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
484
+
err = spindleverify.RunVerification(ctx, instance, did, i.Config.Core.Dev)
483
485
if err != nil {
484
486
l.Error("failed to add spindle to db", "err", err, "instance", instance)
485
487
return err
486
488
}
487
489
488
-
_, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did)
490
+
_, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did)
489
491
if err != nil {
490
492
return fmt.Errorf("failed to mark verified: %w", err)
491
493
}
···
614
616
return nil
615
617
}
616
618
617
-
func (i *Ingester) ingestKnotMember(e *models.Event) error {
619
+
func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error {
618
620
did := e.Did
621
+
rkey := e.Commit.RKey
622
+
619
623
var err error
620
624
621
-
l := i.Logger.With("handler", "ingestKnotMember")
622
-
l = l.With("nsid", e.Commit.Collection)
625
+
l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
626
+
l.Info("ingesting record")
627
+
628
+
ddb, ok := i.Db.Execer.(*db.DB)
629
+
if !ok {
630
+
return fmt.Errorf("failed to index issue record, invalid db cast")
631
+
}
623
632
624
633
switch e.Commit.Operation {
625
634
case models.CommitOperationCreate:
626
635
raw := json.RawMessage(e.Commit.Record)
627
-
record := tangled.KnotMember{}
636
+
record := tangled.RepoIssue{}
628
637
err = json.Unmarshal(raw, &record)
629
638
if err != nil {
630
639
l.Error("invalid record", "err", err)
631
640
return err
632
641
}
633
642
634
-
// only knot owner can invite to knots
635
-
ok, err := i.Enforcer.IsKnotInviteAllowed(did, record.Domain)
636
-
if err != nil || !ok {
637
-
return fmt.Errorf("failed to enforce permissions: %w", err)
643
+
issue := db.IssueFromRecord(did, rkey, record)
644
+
645
+
sanitizer := markup.NewSanitizer()
646
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" {
647
+
return fmt.Errorf("title is empty after HTML sanitization")
648
+
}
649
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" {
650
+
return fmt.Errorf("body is empty after HTML sanitization")
651
+
}
652
+
653
+
tx, err := ddb.BeginTx(ctx, nil)
654
+
if err != nil {
655
+
l.Error("failed to begin transaction", "err", err)
656
+
return err
638
657
}
639
658
640
-
memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject)
659
+
err = db.NewIssue(tx, &issue)
641
660
if err != nil {
661
+
l.Error("failed to create issue", "err", err)
642
662
return err
643
663
}
644
664
645
-
if memberId.Handle.IsInvalidHandle() {
665
+
return nil
666
+
667
+
case models.CommitOperationUpdate:
668
+
raw := json.RawMessage(e.Commit.Record)
669
+
record := tangled.RepoIssue{}
670
+
err = json.Unmarshal(raw, &record)
671
+
if err != nil {
672
+
l.Error("invalid record", "err", err)
646
673
return err
647
674
}
648
675
649
-
err = i.Enforcer.AddKnotMember(record.Domain, memberId.DID.String())
676
+
body := ""
677
+
if record.Body != nil {
678
+
body = *record.Body
679
+
}
680
+
681
+
sanitizer := markup.NewSanitizer()
682
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" {
683
+
return fmt.Errorf("title is empty after HTML sanitization")
684
+
}
685
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
686
+
return fmt.Errorf("body is empty after HTML sanitization")
687
+
}
688
+
689
+
err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body)
650
690
if err != nil {
651
-
return fmt.Errorf("failed to update ACLs: %w", err)
691
+
l.Error("failed to update issue", "err", err)
692
+
return err
652
693
}
653
694
654
-
l.Info("added knot member")
695
+
return nil
696
+
655
697
case models.CommitOperationDelete:
656
-
// we don't store knot members in a table (like we do for spindle)
657
-
// and we can't remove this just yet. possibly fixed if we switch
658
-
// to either:
659
-
// 1. a knot_members table like with spindle and store the rkey
660
-
// 2. use the knot host as the rkey
661
-
//
662
-
// TODO: implement member deletion
663
-
l.Info("skipping knot member delete", "did", did, "rkey", e.Commit.RKey)
698
+
if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil {
699
+
l.Error("failed to delete", "err", err)
700
+
return fmt.Errorf("failed to delete issue record: %w", err)
701
+
}
702
+
703
+
return nil
664
704
}
665
705
666
-
return nil
706
+
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
667
707
}
668
708
669
-
func (i *Ingester) ingestKnot(e *models.Event) error {
709
+
func (i *Ingester) ingestIssueComment(e *models.Event) error {
670
710
did := e.Did
711
+
rkey := e.Commit.RKey
712
+
671
713
var err error
672
714
673
-
l := i.Logger.With("handler", "ingestKnot")
674
-
l = l.With("nsid", e.Commit.Collection)
715
+
l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
716
+
l.Info("ingesting record")
717
+
718
+
ddb, ok := i.Db.Execer.(*db.DB)
719
+
if !ok {
720
+
return fmt.Errorf("failed to index issue comment record, invalid db cast")
721
+
}
675
722
676
723
switch e.Commit.Operation {
677
724
case models.CommitOperationCreate:
678
725
raw := json.RawMessage(e.Commit.Record)
679
-
record := tangled.Knot{}
726
+
record := tangled.RepoIssueComment{}
680
727
err = json.Unmarshal(raw, &record)
681
728
if err != nil {
682
729
l.Error("invalid record", "err", err)
683
730
return err
684
731
}
685
732
686
-
domain := e.Commit.RKey
687
-
688
-
ddb, ok := i.Db.Execer.(*db.DB)
689
-
if !ok {
690
-
return fmt.Errorf("failed to index profile record, invalid db cast")
691
-
}
692
-
693
-
err := db.AddKnot(ddb, domain, did)
733
+
comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record)
694
734
if err != nil {
695
-
l.Error("failed to add knot to db", "err", err, "domain", domain)
735
+
l.Error("failed to parse comment from record", "err", err)
696
736
return err
697
737
}
698
738
699
-
err = serververify.RunVerification(context.Background(), domain, did, i.Config.Core.Dev)
700
-
if err != nil {
701
-
l.Error("failed to verify knot", "err", err, "domain", domain)
702
-
return err
739
+
sanitizer := markup.NewSanitizer()
740
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" {
741
+
return fmt.Errorf("body is empty after HTML sanitization")
703
742
}
704
743
705
-
err = serververify.MarkKnotVerified(ddb, i.Enforcer, domain, did)
744
+
err = db.NewIssueComment(ddb, &comment)
706
745
if err != nil {
707
-
return fmt.Errorf("failed to mark verified: %w", err)
746
+
l.Error("failed to create issue comment", "err", err)
747
+
return err
708
748
}
709
749
710
750
return nil
711
751
712
-
case models.CommitOperationDelete:
713
-
domain := e.Commit.RKey
714
-
715
-
ddb, ok := i.Db.Execer.(*db.DB)
716
-
if !ok {
717
-
return fmt.Errorf("failed to index knot record, invalid db cast")
718
-
}
719
-
720
-
// get record from db first
721
-
registrations, err := db.GetRegistrations(
722
-
ddb,
723
-
db.FilterEq("domain", domain),
724
-
db.FilterEq("did", did),
725
-
)
752
+
case models.CommitOperationUpdate:
753
+
raw := json.RawMessage(e.Commit.Record)
754
+
record := tangled.RepoIssueComment{}
755
+
err = json.Unmarshal(raw, &record)
726
756
if err != nil {
727
-
return fmt.Errorf("failed to get registration: %w", err)
728
-
}
729
-
if len(registrations) != 1 {
730
-
return fmt.Errorf("got incorret number of registrations: %d, expected 1", len(registrations))
757
+
l.Error("invalid record", "err", err)
758
+
return err
731
759
}
732
-
registration := registrations[0]
733
760
734
-
tx, err := ddb.Begin()
735
-
if err != nil {
736
-
return err
761
+
sanitizer := markup.NewSanitizer()
762
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" {
763
+
return fmt.Errorf("body is empty after HTML sanitization")
737
764
}
738
-
defer func() {
739
-
tx.Rollback()
740
-
i.Enforcer.E.LoadPolicy()
741
-
}()
742
765
743
-
err = db.DeleteKnot(
744
-
tx,
745
-
db.FilterEq("did", did),
746
-
db.FilterEq("domain", domain),
747
-
)
766
+
err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body)
748
767
if err != nil {
768
+
l.Error("failed to update issue comment", "err", err)
749
769
return err
750
770
}
751
771
752
-
if registration.Registered != nil {
753
-
err = i.Enforcer.RemoveKnot(domain)
754
-
if err != nil {
755
-
return err
756
-
}
757
-
}
772
+
return nil
758
773
759
-
err = tx.Commit()
760
-
if err != nil {
761
-
return err
774
+
case models.CommitOperationDelete:
775
+
if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil {
776
+
l.Error("failed to delete", "err", err)
777
+
return fmt.Errorf("failed to delete issue comment record: %w", err)
762
778
}
763
779
764
-
err = i.Enforcer.E.SavePolicy()
765
-
if err != nil {
766
-
return err
767
-
}
780
+
return nil
768
781
}
769
782
770
-
return nil
783
+
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
771
784
}
+4
-9
appview/issues/issues.go
+4
-9
appview/issues/issues.go
···
278
278
}
279
279
280
280
createdAt := time.Now().Format(time.RFC3339)
281
-
commentIdInt64 := int64(commentId)
282
281
ownerDid := user.Did
283
282
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt)
284
283
if err != nil {
···
302
301
Val: &tangled.RepoIssueComment{
303
302
Repo: &atUri,
304
303
Issue: issueAt,
305
-
CommentId: &commentIdInt64,
306
304
Owner: &ownerDid,
307
305
Body: body,
308
306
CreatedAt: createdAt,
···
451
449
repoAt := record["repo"].(string)
452
450
issueAt := record["issue"].(string)
453
451
createdAt := record["createdAt"].(string)
454
-
commentIdInt64 := int64(commentIdInt)
455
452
456
453
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
457
454
Collection: tangled.RepoIssueCommentNSID,
···
462
459
Val: &tangled.RepoIssueComment{
463
460
Repo: &repoAt,
464
461
Issue: issueAt,
465
-
CommentId: &commentIdInt64,
466
462
Owner: &comment.OwnerDid,
467
463
Body: newBody,
468
464
CreatedAt: createdAt,
···
687
683
Rkey: issue.Rkey,
688
684
Record: &lexutil.LexiconTypeDecoder{
689
685
Val: &tangled.RepoIssue{
690
-
Repo: atUri,
691
-
Title: title,
692
-
Body: &body,
693
-
Owner: user.Did,
694
-
IssueId: int64(issue.IssueId),
686
+
Repo: atUri,
687
+
Title: title,
688
+
Body: &body,
689
+
Owner: user.Did,
695
690
},
696
691
},
697
692
})
+217
-444
appview/knots/knots.go
+217
-444
appview/knots/knots.go
···
1
1
package knots
2
2
3
3
import (
4
-
"errors"
4
+
"context"
5
+
"crypto/hmac"
6
+
"crypto/sha256"
7
+
"encoding/hex"
5
8
"fmt"
6
-
"log"
7
9
"log/slog"
8
10
"net/http"
9
-
"slices"
11
+
"strings"
10
12
"time"
11
13
12
14
"github.com/go-chi/chi/v5"
···
16
18
"tangled.sh/tangled.sh/core/appview/middleware"
17
19
"tangled.sh/tangled.sh/core/appview/oauth"
18
20
"tangled.sh/tangled.sh/core/appview/pages"
19
-
"tangled.sh/tangled.sh/core/appview/serververify"
20
21
"tangled.sh/tangled.sh/core/eventconsumer"
21
22
"tangled.sh/tangled.sh/core/idresolver"
23
+
"tangled.sh/tangled.sh/core/knotclient"
22
24
"tangled.sh/tangled.sh/core/rbac"
23
25
"tangled.sh/tangled.sh/core/tid"
24
26
···
37
39
Knotstream *eventconsumer.Consumer
38
40
}
39
41
40
-
func (k *Knots) Router() http.Handler {
42
+
func (k *Knots) Router(mw *middleware.Middleware) http.Handler {
41
43
r := chi.NewRouter()
42
44
43
-
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots)
44
-
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register)
45
-
46
-
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard)
47
-
r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete)
45
+
r.Use(middleware.AuthMiddleware(k.OAuth))
48
46
49
-
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry)
50
-
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember)
51
-
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember)
47
+
r.Get("/", k.index)
48
+
r.Post("/key", k.generateKey)
52
49
53
-
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner)
50
+
r.Route("/{domain}", func(r chi.Router) {
51
+
r.Post("/init", k.init)
52
+
r.Get("/", k.dashboard)
53
+
r.Route("/member", func(r chi.Router) {
54
+
r.Use(mw.KnotOwner())
55
+
r.Get("/", k.members)
56
+
r.Put("/", k.addMember)
57
+
r.Delete("/", k.removeMember)
58
+
})
59
+
})
54
60
55
61
return r
56
62
}
57
63
58
-
func (k *Knots) knots(w http.ResponseWriter, r *http.Request) {
64
+
// get knots registered by this user
65
+
func (k *Knots) index(w http.ResponseWriter, r *http.Request) {
66
+
l := k.Logger.With("handler", "index")
67
+
59
68
user := k.OAuth.GetUser(r)
60
-
registrations, err := db.GetRegistrations(
61
-
k.Db,
62
-
db.FilterEq("did", user.Did),
63
-
)
69
+
registrations, err := db.RegistrationsByDid(k.Db, user.Did)
64
70
if err != nil {
65
-
k.Logger.Error("failed to fetch knot registrations", "err", err)
66
-
w.WriteHeader(http.StatusInternalServerError)
67
-
return
71
+
l.Error("failed to get registrations by did", "err", err)
68
72
}
69
73
70
74
k.Pages.Knots(w, pages.KnotsParams{
···
73
77
})
74
78
}
75
79
76
-
func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
77
-
l := k.Logger.With("handler", "dashboard")
80
+
// requires auth
81
+
func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) {
82
+
l := k.Logger.With("handler", "generateKey")
78
83
79
84
user := k.OAuth.GetUser(r)
80
-
l = l.With("user", user.Did)
85
+
did := user.Did
86
+
l = l.With("did", did)
81
87
82
-
domain := chi.URLParam(r, "domain")
88
+
// check if domain is valid url, and strip extra bits down to just host
89
+
domain := r.FormValue("domain")
83
90
if domain == "" {
91
+
l.Error("empty domain")
92
+
http.Error(w, "Invalid form", http.StatusBadRequest)
84
93
return
85
94
}
86
95
l = l.With("domain", domain)
87
96
88
-
registrations, err := db.GetRegistrations(
89
-
k.Db,
90
-
db.FilterEq("did", user.Did),
91
-
db.FilterEq("domain", domain),
92
-
)
93
-
if err != nil {
94
-
l.Error("failed to get registrations", "err", err)
95
-
http.Error(w, "Not found", http.StatusNotFound)
96
-
return
97
-
}
98
-
if len(registrations) != 1 {
99
-
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
100
-
return
97
+
noticeId := "registration-error"
98
+
fail := func() {
99
+
k.Pages.Notice(w, noticeId, "Failed to generate registration key.")
101
100
}
102
-
registration := registrations[0]
103
101
104
-
members, err := k.Enforcer.GetUserByRole("server:member", domain)
102
+
key, err := db.GenerateRegistrationKey(k.Db, domain, did)
105
103
if err != nil {
106
-
l.Error("failed to get knot members", "err", err)
107
-
http.Error(w, "Not found", http.StatusInternalServerError)
104
+
l.Error("failed to generate registration key", "err", err)
105
+
fail()
108
106
return
109
107
}
110
-
slices.Sort(members)
111
108
112
-
repos, err := db.GetRepos(
113
-
k.Db,
114
-
0,
115
-
db.FilterEq("knot", domain),
116
-
)
109
+
allRegs, err := db.RegistrationsByDid(k.Db, did)
117
110
if err != nil {
118
-
l.Error("failed to get knot repos", "err", err)
119
-
http.Error(w, "Not found", http.StatusInternalServerError)
111
+
l.Error("failed to generate registration key", "err", err)
112
+
fail()
120
113
return
121
114
}
122
115
123
-
// organize repos by did
124
-
repoMap := make(map[string][]db.Repo)
125
-
for _, r := range repos {
126
-
repoMap[r.Did] = append(repoMap[r.Did], r)
127
-
}
128
-
129
-
k.Pages.Knot(w, pages.KnotParams{
130
-
LoggedInUser: user,
131
-
Registration: ®istration,
132
-
Members: members,
133
-
Repos: repoMap,
134
-
IsOwner: true,
116
+
k.Pages.KnotListingFull(w, pages.KnotListingFullParams{
117
+
Registrations: allRegs,
118
+
})
119
+
k.Pages.KnotSecret(w, pages.KnotSecretParams{
120
+
Secret: key,
135
121
})
136
122
}
137
123
138
-
func (k *Knots) register(w http.ResponseWriter, r *http.Request) {
124
+
// create a signed request and check if a node responds to that
125
+
func (k *Knots) init(w http.ResponseWriter, r *http.Request) {
126
+
l := k.Logger.With("handler", "init")
139
127
user := k.OAuth.GetUser(r)
140
-
l := k.Logger.With("handler", "register")
141
128
142
-
noticeId := "register-error"
143
-
defaultErr := "Failed to register knot. Try again later."
129
+
noticeId := "operation-error"
130
+
defaultErr := "Failed to initialize knot. Try again later."
144
131
fail := func() {
145
132
k.Pages.Notice(w, noticeId, defaultErr)
146
133
}
147
134
148
-
domain := r.FormValue("domain")
135
+
domain := chi.URLParam(r, "domain")
149
136
if domain == "" {
150
-
k.Pages.Notice(w, noticeId, "Incomplete form.")
137
+
http.Error(w, "malformed url", http.StatusBadRequest)
151
138
return
152
139
}
153
140
l = l.With("domain", domain)
154
-
l = l.With("user", user.Did)
155
141
156
-
tx, err := k.Db.Begin()
157
-
if err != nil {
158
-
l.Error("failed to start transaction", "err", err)
159
-
fail()
160
-
return
161
-
}
162
-
defer func() {
163
-
tx.Rollback()
164
-
k.Enforcer.E.LoadPolicy()
165
-
}()
142
+
l.Info("checking domain")
166
143
167
-
err = db.AddKnot(tx, domain, user.Did)
144
+
registration, err := db.RegistrationByDomain(k.Db, domain)
168
145
if err != nil {
169
-
l.Error("failed to insert", "err", err)
146
+
l.Error("failed to get registration for domain", "err", err)
170
147
fail()
171
148
return
172
149
}
173
-
174
-
err = k.Enforcer.AddKnot(domain)
175
-
if err != nil {
176
-
l.Error("failed to create knot", "err", err)
177
-
fail()
150
+
if registration.ByDid != user.Did {
151
+
l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did)
152
+
w.WriteHeader(http.StatusUnauthorized)
178
153
return
179
154
}
180
155
181
-
// create record on pds
182
-
client, err := k.OAuth.AuthorizedClient(r)
156
+
secret, err := db.GetRegistrationKey(k.Db, domain)
183
157
if err != nil {
184
-
l.Error("failed to authorize client", "err", err)
158
+
l.Error("failed to get registration key for domain", "err", err)
185
159
fail()
186
160
return
187
161
}
188
162
189
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
190
-
var exCid *string
191
-
if ex != nil {
192
-
exCid = ex.Cid
193
-
}
194
-
195
-
// re-announce by registering under same rkey
196
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
197
-
Collection: tangled.KnotNSID,
198
-
Repo: user.Did,
199
-
Rkey: domain,
200
-
Record: &lexutil.LexiconTypeDecoder{
201
-
Val: &tangled.Knot{
202
-
CreatedAt: time.Now().Format(time.RFC3339),
203
-
},
204
-
},
205
-
SwapRecord: exCid,
206
-
})
207
-
163
+
client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
208
164
if err != nil {
209
-
l.Error("failed to put record", "err", err)
165
+
l.Error("failed to create knotclient", "err", err)
210
166
fail()
211
167
return
212
168
}
213
169
214
-
err = tx.Commit()
170
+
resp, err := client.Init(user.Did)
215
171
if err != nil {
216
-
l.Error("failed to commit transaction", "err", err)
217
-
fail()
172
+
k.Pages.Notice(w, noticeId, fmt.Sprintf("Failed to make request: %s", err.Error()))
173
+
l.Error("failed to make init request", "err", err)
218
174
return
219
175
}
220
176
221
-
err = k.Enforcer.E.SavePolicy()
222
-
if err != nil {
223
-
l.Error("failed to update ACL", "err", err)
224
-
k.Pages.HxRefresh(w)
177
+
if resp.StatusCode == http.StatusConflict {
178
+
k.Pages.Notice(w, noticeId, "This knot is already registered")
179
+
l.Error("knot already registered", "statuscode", resp.StatusCode)
225
180
return
226
181
}
227
182
228
-
// begin verification
229
-
err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev)
230
-
if err != nil {
231
-
l.Error("verification failed", "err", err)
232
-
k.Pages.HxRefresh(w)
183
+
if resp.StatusCode != http.StatusNoContent {
184
+
k.Pages.Notice(w, noticeId, fmt.Sprintf("Received status %d from knot, expected %d", resp.StatusCode, http.StatusNoContent))
185
+
l.Error("incorrect statuscode returned", "statuscode", resp.StatusCode, "expected", http.StatusNoContent)
233
186
return
234
187
}
235
188
236
-
err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did)
189
+
// verify response mac
190
+
signature := resp.Header.Get("X-Signature")
191
+
signatureBytes, err := hex.DecodeString(signature)
237
192
if err != nil {
238
-
l.Error("failed to mark verified", "err", err)
239
-
k.Pages.HxRefresh(w)
240
193
return
241
194
}
242
195
243
-
// add this knot to knotstream
244
-
go k.Knotstream.AddSource(
245
-
r.Context(),
246
-
eventconsumer.NewKnotSource(domain),
247
-
)
196
+
expectedMac := hmac.New(sha256.New, []byte(secret))
197
+
expectedMac.Write([]byte("ok"))
248
198
249
-
// ok
250
-
k.Pages.HxRefresh(w)
251
-
}
252
-
253
-
func (k *Knots) delete(w http.ResponseWriter, r *http.Request) {
254
-
user := k.OAuth.GetUser(r)
255
-
l := k.Logger.With("handler", "delete")
256
-
257
-
noticeId := "operation-error"
258
-
defaultErr := "Failed to delete knot. Try again later."
259
-
fail := func() {
260
-
k.Pages.Notice(w, noticeId, defaultErr)
199
+
if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) {
200
+
k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.")
201
+
l.Error("signature mismatch", "bytes", signatureBytes)
202
+
return
261
203
}
262
204
263
-
domain := chi.URLParam(r, "domain")
264
-
if domain == "" {
265
-
l.Error("empty domain")
205
+
tx, err := k.Db.BeginTx(r.Context(), nil)
206
+
if err != nil {
207
+
l.Error("failed to start tx", "err", err)
266
208
fail()
267
209
return
268
210
}
211
+
defer func() {
212
+
tx.Rollback()
213
+
err = k.Enforcer.E.LoadPolicy()
214
+
if err != nil {
215
+
l.Error("rollback failed", "err", err)
216
+
}
217
+
}()
269
218
270
-
// get record from db first
271
-
registrations, err := db.GetRegistrations(
272
-
k.Db,
273
-
db.FilterEq("did", user.Did),
274
-
db.FilterEq("domain", domain),
275
-
)
219
+
// mark as registered
220
+
err = db.Register(tx, domain)
276
221
if err != nil {
277
-
l.Error("failed to get registration", "err", err)
222
+
l.Error("failed to register domain", "err", err)
278
223
fail()
279
224
return
280
225
}
281
-
if len(registrations) != 1 {
282
-
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
283
-
fail()
284
-
return
285
-
}
286
-
registration := registrations[0]
287
226
288
-
tx, err := k.Db.Begin()
227
+
// set permissions for this did as owner
228
+
reg, err := db.RegistrationByDomain(tx, domain)
289
229
if err != nil {
290
-
l.Error("failed to start txn", "err", err)
230
+
l.Error("failed get registration by domain", "err", err)
291
231
fail()
292
232
return
293
233
}
294
-
defer func() {
295
-
tx.Rollback()
296
-
k.Enforcer.E.LoadPolicy()
297
-
}()
298
234
299
-
err = db.DeleteKnot(
300
-
tx,
301
-
db.FilterEq("did", user.Did),
302
-
db.FilterEq("domain", domain),
303
-
)
235
+
// add basic acls for this domain
236
+
err = k.Enforcer.AddKnot(domain)
304
237
if err != nil {
305
-
l.Error("failed to delete registration", "err", err)
238
+
l.Error("failed to add knot to enforcer", "err", err)
306
239
fail()
307
240
return
308
241
}
309
242
310
-
// delete from enforcer if it was registered
311
-
if registration.Registered != nil {
312
-
err = k.Enforcer.RemoveKnot(domain)
313
-
if err != nil {
314
-
l.Error("failed to update ACL", "err", err)
315
-
fail()
316
-
return
317
-
}
318
-
}
319
-
320
-
client, err := k.OAuth.AuthorizedClient(r)
243
+
// add this did as owner of this domain
244
+
err = k.Enforcer.AddKnotOwner(domain, reg.ByDid)
321
245
if err != nil {
322
-
l.Error("failed to authorize client", "err", err)
246
+
l.Error("failed to add knot owner to enforcer", "err", err)
323
247
fail()
324
248
return
325
249
}
326
250
327
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
328
-
Collection: tangled.KnotNSID,
329
-
Repo: user.Did,
330
-
Rkey: domain,
331
-
})
332
-
if err != nil {
333
-
// non-fatal
334
-
l.Error("failed to delete record", "err", err)
335
-
}
336
-
337
251
err = tx.Commit()
338
252
if err != nil {
339
-
l.Error("failed to delete knot", "err", err)
253
+
l.Error("failed to commit changes", "err", err)
340
254
fail()
341
255
return
342
256
}
343
257
344
258
err = k.Enforcer.E.SavePolicy()
345
259
if err != nil {
346
-
l.Error("failed to update ACL", "err", err)
347
-
k.Pages.HxRefresh(w)
260
+
l.Error("failed to update ACLs", "err", err)
261
+
fail()
348
262
return
349
263
}
350
264
351
-
shouldRedirect := r.Header.Get("shouldRedirect")
352
-
if shouldRedirect == "true" {
353
-
k.Pages.HxRedirect(w, "/knots")
354
-
return
355
-
}
265
+
// add this knot to knotstream
266
+
go k.Knotstream.AddSource(
267
+
context.Background(),
268
+
eventconsumer.NewKnotSource(domain),
269
+
)
356
270
357
-
w.Write([]byte{})
271
+
k.Pages.KnotListing(w, pages.KnotListingParams{
272
+
Registration: *reg,
273
+
})
358
274
}
359
275
360
-
func (k *Knots) retry(w http.ResponseWriter, r *http.Request) {
361
-
user := k.OAuth.GetUser(r)
362
-
l := k.Logger.With("handler", "retry")
363
-
364
-
noticeId := "operation-error"
365
-
defaultErr := "Failed to verify knot. Try again later."
276
+
func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
277
+
l := k.Logger.With("handler", "dashboard")
366
278
fail := func() {
367
-
k.Pages.Notice(w, noticeId, defaultErr)
279
+
w.WriteHeader(http.StatusInternalServerError)
368
280
}
369
281
370
282
domain := chi.URLParam(r, "domain")
371
283
if domain == "" {
372
-
l.Error("empty domain")
373
-
fail()
284
+
http.Error(w, "malformed url", http.StatusBadRequest)
374
285
return
375
286
}
376
287
l = l.With("domain", domain)
377
-
l = l.With("user", user.Did)
378
288
379
-
// get record from db first
380
-
registrations, err := db.GetRegistrations(
381
-
k.Db,
382
-
db.FilterEq("did", user.Did),
383
-
db.FilterEq("domain", domain),
384
-
)
289
+
user := k.OAuth.GetUser(r)
290
+
l = l.With("did", user.Did)
291
+
292
+
// dashboard is only available to owners
293
+
ok, err := k.Enforcer.IsKnotOwner(user.Did, domain)
385
294
if err != nil {
386
-
l.Error("failed to get registration", "err", err)
295
+
l.Error("failed to query enforcer", "err", err)
387
296
fail()
388
-
return
389
297
}
390
-
if len(registrations) != 1 {
391
-
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
392
-
fail()
298
+
if !ok {
299
+
http.Error(w, "only owners can view dashboards", http.StatusUnauthorized)
393
300
return
394
301
}
395
-
registration := registrations[0]
396
302
397
-
// begin verification
398
-
err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev)
303
+
reg, err := db.RegistrationByDomain(k.Db, domain)
399
304
if err != nil {
400
-
l.Error("verification failed", "err", err)
401
-
402
-
if errors.Is(err, serververify.FetchError) {
403
-
k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.")
404
-
return
405
-
}
406
-
407
-
if e, ok := err.(*serververify.OwnerMismatch); ok {
408
-
k.Pages.Notice(w, noticeId, e.Error())
409
-
return
410
-
}
411
-
305
+
l.Error("failed to get registration by domain", "err", err)
412
306
fail()
413
307
return
414
308
}
415
309
416
-
err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did)
417
-
if err != nil {
418
-
l.Error("failed to mark verified", "err", err)
419
-
k.Pages.Notice(w, noticeId, err.Error())
420
-
return
421
-
}
422
-
423
-
// if this knot was previously read-only, then emit a record too
424
-
//
425
-
// this is part of migrating from the old knot system to the new one
426
-
if registration.ReadOnly {
427
-
// re-announce by registering under same rkey
428
-
client, err := k.OAuth.AuthorizedClient(r)
310
+
var members []string
311
+
if reg.Registered != nil {
312
+
members, err = k.Enforcer.GetUserByRole("server:member", domain)
429
313
if err != nil {
430
-
l.Error("failed to authorize client", "err", err)
314
+
l.Error("failed to get members list", "err", err)
431
315
fail()
432
316
return
433
317
}
434
-
435
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
436
-
var exCid *string
437
-
if ex != nil {
438
-
exCid = ex.Cid
439
-
}
440
-
441
-
// ignore the error here
442
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
443
-
Collection: tangled.KnotNSID,
444
-
Repo: user.Did,
445
-
Rkey: domain,
446
-
Record: &lexutil.LexiconTypeDecoder{
447
-
Val: &tangled.Knot{
448
-
CreatedAt: time.Now().Format(time.RFC3339),
449
-
},
450
-
},
451
-
SwapRecord: exCid,
452
-
})
453
-
if err != nil {
454
-
l.Error("non-fatal: failed to reannouce knot", "err", err)
455
-
}
456
318
}
457
319
458
-
// add this knot to knotstream
459
-
go k.Knotstream.AddSource(
460
-
r.Context(),
461
-
eventconsumer.NewKnotSource(domain),
462
-
)
463
-
464
-
shouldRefresh := r.Header.Get("shouldRefresh")
465
-
if shouldRefresh == "true" {
466
-
k.Pages.HxRefresh(w)
467
-
return
468
-
}
469
-
470
-
// Get updated registration to show
471
-
registrations, err = db.GetRegistrations(
320
+
repos, err := db.GetRepos(
472
321
k.Db,
473
-
db.FilterEq("did", user.Did),
474
-
db.FilterEq("domain", domain),
322
+
0,
323
+
db.FilterEq("knot", domain),
324
+
db.FilterIn("did", members),
475
325
)
476
326
if err != nil {
477
-
l.Error("failed to get registration", "err", err)
327
+
l.Error("failed to get repos list", "err", err)
478
328
fail()
479
329
return
480
330
}
481
-
if len(registrations) != 1 {
482
-
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
483
-
fail()
484
-
return
331
+
// convert to map
332
+
repoByMember := make(map[string][]db.Repo)
333
+
for _, r := range repos {
334
+
repoByMember[r.Did] = append(repoByMember[r.Did], r)
485
335
}
486
-
updatedRegistration := registrations[0]
487
336
488
-
log.Println(updatedRegistration)
489
-
490
-
w.Header().Set("HX-Reswap", "outerHTML")
491
-
k.Pages.KnotListing(w, pages.KnotListingParams{
492
-
Registration: &updatedRegistration,
337
+
k.Pages.Knot(w, pages.KnotParams{
338
+
LoggedInUser: user,
339
+
Registration: reg,
340
+
Members: members,
341
+
Repos: repoByMember,
342
+
IsOwner: true,
493
343
})
494
344
}
495
345
496
-
func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
497
-
user := k.OAuth.GetUser(r)
498
-
l := k.Logger.With("handler", "addMember")
346
+
// list members of domain, requires auth and requires owner status
347
+
func (k *Knots) members(w http.ResponseWriter, r *http.Request) {
348
+
l := k.Logger.With("handler", "members")
499
349
500
350
domain := chi.URLParam(r, "domain")
501
351
if domain == "" {
502
-
l.Error("empty domain")
503
-
http.Error(w, "Not found", http.StatusNotFound)
352
+
http.Error(w, "malformed url", http.StatusBadRequest)
504
353
return
505
354
}
506
355
l = l.With("domain", domain)
507
-
l = l.With("user", user.Did)
508
356
509
-
registrations, err := db.GetRegistrations(
510
-
k.Db,
511
-
db.FilterEq("did", user.Did),
512
-
db.FilterEq("domain", domain),
513
-
db.FilterIsNot("registered", "null"),
514
-
)
357
+
// list all members for this domain
358
+
memberDids, err := k.Enforcer.GetUserByRole("server:member", domain)
515
359
if err != nil {
516
-
l.Error("failed to get registration", "err", err)
360
+
w.Write([]byte("failed to fetch member list"))
361
+
return
362
+
}
363
+
364
+
w.Write([]byte(strings.Join(memberDids, "\n")))
365
+
}
366
+
367
+
// add member to domain, requires auth and requires invite access
368
+
func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
369
+
l := k.Logger.With("handler", "members")
370
+
371
+
domain := chi.URLParam(r, "domain")
372
+
if domain == "" {
373
+
http.Error(w, "malformed url", http.StatusBadRequest)
517
374
return
518
375
}
519
-
if len(registrations) != 1 {
520
-
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
376
+
l = l.With("domain", domain)
377
+
378
+
reg, err := db.RegistrationByDomain(k.Db, domain)
379
+
if err != nil {
380
+
l.Error("failed to get registration by domain", "err", err)
381
+
http.Error(w, "malformed url", http.StatusBadRequest)
521
382
return
522
383
}
523
-
registration := registrations[0]
524
384
525
-
noticeId := fmt.Sprintf("add-member-error-%d", registration.Id)
385
+
noticeId := fmt.Sprintf("add-member-error-%d", reg.Id)
386
+
l = l.With("notice-id", noticeId)
526
387
defaultErr := "Failed to add member. Try again later."
527
388
fail := func() {
528
389
k.Pages.Notice(w, noticeId, defaultErr)
529
390
}
530
391
531
-
member := r.FormValue("member")
532
-
if member == "" {
533
-
l.Error("empty member")
534
-
k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
392
+
subjectIdentifier := r.FormValue("subject")
393
+
if subjectIdentifier == "" {
394
+
http.Error(w, "malformed form", http.StatusBadRequest)
535
395
return
536
396
}
537
-
l = l.With("member", member)
397
+
l = l.With("subjectIdentifier", subjectIdentifier)
538
398
539
-
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
399
+
subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier)
540
400
if err != nil {
541
-
l.Error("failed to resolve member identity to handle", "err", err)
401
+
l.Error("failed to resolve identity", "err", err)
542
402
k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
543
403
return
544
404
}
545
-
if memberId.Handle.IsInvalidHandle() {
546
-
l.Error("failed to resolve member identity to handle")
547
-
k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
548
-
return
549
-
}
405
+
l = l.With("subjectDid", subjectIdentity.DID)
406
+
407
+
l.Info("adding member to knot")
550
408
551
-
// write to pds
409
+
// announce this relation into the firehose, store into owners' pds
552
410
client, err := k.OAuth.AuthorizedClient(r)
553
411
if err != nil {
554
-
l.Error("failed to authorize client", "err", err)
412
+
l.Error("failed to create client", "err", err)
555
413
fail()
556
414
return
557
415
}
558
416
559
-
rkey := tid.TID()
560
-
561
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
417
+
currentUser := k.OAuth.GetUser(r)
418
+
createdAt := time.Now().Format(time.RFC3339)
419
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
562
420
Collection: tangled.KnotMemberNSID,
563
-
Repo: user.Did,
564
-
Rkey: rkey,
421
+
Repo: currentUser.Did,
422
+
Rkey: tid.TID(),
565
423
Record: &lexutil.LexiconTypeDecoder{
566
424
Val: &tangled.KnotMember{
567
-
CreatedAt: time.Now().Format(time.RFC3339),
425
+
Subject: subjectIdentity.DID.String(),
568
426
Domain: domain,
569
-
Subject: memberId.DID.String(),
570
-
},
571
-
},
427
+
CreatedAt: createdAt,
428
+
}},
572
429
})
430
+
// invalid record
573
431
if err != nil {
574
-
l.Error("failed to add record to PDS", "err", err)
575
-
k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.")
432
+
l.Error("failed to write to PDS", "err", err)
433
+
fail()
576
434
return
577
435
}
436
+
l = l.With("at-uri", resp.Uri)
437
+
l.Info("wrote record to PDS")
578
438
579
-
err = k.Enforcer.AddKnotMember(domain, memberId.DID.String())
439
+
secret, err := db.GetRegistrationKey(k.Db, domain)
580
440
if err != nil {
581
-
l.Error("failed to add member to ACLs", "err", err)
441
+
l.Error("failed to get registration key", "err", err)
582
442
fail()
583
443
return
584
444
}
585
445
586
-
err = k.Enforcer.E.SavePolicy()
446
+
ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
587
447
if err != nil {
588
-
l.Error("failed to save ACL policy", "err", err)
448
+
l.Error("failed to create client", "err", err)
589
449
fail()
590
450
return
591
451
}
592
452
593
-
// success
594
-
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
595
-
}
596
-
597
-
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
598
-
user := k.OAuth.GetUser(r)
599
-
l := k.Logger.With("handler", "removeMember")
600
-
601
-
noticeId := "operation-error"
602
-
defaultErr := "Failed to remove member. Try again later."
603
-
fail := func() {
604
-
k.Pages.Notice(w, noticeId, defaultErr)
605
-
}
606
-
607
-
domain := chi.URLParam(r, "domain")
608
-
if domain == "" {
609
-
l.Error("empty domain")
610
-
fail()
611
-
return
612
-
}
613
-
l = l.With("domain", domain)
614
-
l = l.With("user", user.Did)
615
-
616
-
registrations, err := db.GetRegistrations(
617
-
k.Db,
618
-
db.FilterEq("did", user.Did),
619
-
db.FilterEq("domain", domain),
620
-
db.FilterIsNot("registered", "null"),
621
-
)
453
+
ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
622
454
if err != nil {
623
-
l.Error("failed to get registration", "err", err)
624
-
return
625
-
}
626
-
if len(registrations) != 1 {
627
-
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
455
+
l.Error("failed to reach knotserver", "err", err)
456
+
k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.")
628
457
return
629
458
}
630
459
631
-
member := r.FormValue("member")
632
-
if member == "" {
633
-
l.Error("empty member")
634
-
k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
635
-
return
636
-
}
637
-
l = l.With("member", member)
638
-
639
-
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
640
-
if err != nil {
641
-
l.Error("failed to resolve member identity to handle", "err", err)
642
-
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
643
-
return
644
-
}
645
-
if memberId.Handle.IsInvalidHandle() {
646
-
l.Error("failed to resolve member identity to handle")
647
-
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
648
-
return
649
-
}
650
-
651
-
// remove from enforcer
652
-
err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String())
653
-
if err != nil {
654
-
l.Error("failed to update ACLs", "err", err)
655
-
fail()
656
-
return
657
-
}
658
-
659
-
client, err := k.OAuth.AuthorizedClient(r)
660
-
if err != nil {
661
-
l.Error("failed to authorize client", "err", err)
662
-
fail()
460
+
if ksResp.StatusCode != http.StatusNoContent {
461
+
l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent)
462
+
k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent))
663
463
return
664
464
}
665
465
666
-
// TODO: We need to track the rkey for knot members to delete the record
667
-
// For now, just remove from ACLs
668
-
_ = client
669
-
670
-
// commit everything
671
-
err = k.Enforcer.E.SavePolicy()
466
+
err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
672
467
if err != nil {
673
-
l.Error("failed to save ACLs", "err", err)
468
+
l.Error("failed to add member to enforcer", "err", err)
674
469
fail()
675
470
return
676
471
}
677
472
678
-
// ok
679
-
k.Pages.HxRefresh(w)
473
+
// success
474
+
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
680
475
}
681
476
682
-
func (k *Knots) banner(w http.ResponseWriter, r *http.Request) {
683
-
user := k.OAuth.GetUser(r)
684
-
l := k.Logger.With("handler", "removeMember")
685
-
l = l.With("did", user.Did)
686
-
l = l.With("handle", user.Handle)
687
-
688
-
registrations, err := db.GetRegistrations(
689
-
k.Db,
690
-
db.FilterEq("did", user.Did),
691
-
db.FilterEq("read_only", 1),
692
-
)
693
-
if err != nil {
694
-
l.Error("non-fatal: failed to get registrations")
695
-
return
696
-
}
697
-
698
-
if registrations == nil {
699
-
return
700
-
}
701
-
702
-
k.Pages.KnotBanner(w, pages.KnotBannerParams{
703
-
Registrations: registrations,
704
-
})
477
+
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
705
478
}
+3
-3
appview/middleware/middleware.go
+3
-3
appview/middleware/middleware.go
···
217
217
if err != nil {
218
218
// invalid did or handle
219
219
log.Println("failed to resolve repo")
220
-
mw.pages.ErrorKnot404(w)
220
+
mw.pages.Error404(w)
221
221
return
222
222
}
223
223
···
234
234
f, err := mw.repoResolver.Resolve(r)
235
235
if err != nil {
236
236
log.Println("failed to fully resolve repo", err)
237
-
mw.pages.ErrorKnot404(w)
237
+
http.Error(w, "invalid repo url", http.StatusNotFound)
238
238
return
239
239
}
240
240
···
283
283
f, err := mw.repoResolver.Resolve(r)
284
284
if err != nil {
285
285
log.Println("failed to fully resolve repo", err)
286
-
mw.pages.ErrorKnot404(w)
286
+
http.Error(w, "invalid repo url", http.StatusNotFound)
287
287
return
288
288
}
289
289
+82
-98
appview/oauth/handler/handler.go
+82
-98
appview/oauth/handler/handler.go
···
8
8
"log"
9
9
"net/http"
10
10
"net/url"
11
-
"slices"
12
11
"strings"
13
12
"time"
14
13
···
26
25
"tangled.sh/tangled.sh/core/appview/oauth/client"
27
26
"tangled.sh/tangled.sh/core/appview/pages"
28
27
"tangled.sh/tangled.sh/core/idresolver"
28
+
"tangled.sh/tangled.sh/core/knotclient"
29
29
"tangled.sh/tangled.sh/core/rbac"
30
30
"tangled.sh/tangled.sh/core/tid"
31
31
)
···
353
353
return pubKey, nil
354
354
}
355
355
356
-
var (
357
-
tangledHandle = "tangled.sh"
358
-
tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli"
359
-
defaultSpindle = "spindle.tangled.sh"
360
-
defaultKnot = "knot1.tangled.sh"
361
-
)
362
-
363
356
func (o *OAuthHandler) addToDefaultSpindle(did string) {
364
357
// use the tangled.sh app password to get an accessJwt
365
358
// and create an sh.tangled.spindle.member record with that
359
+
360
+
defaultSpindle := "spindle.tangled.sh"
361
+
appPassword := o.config.Core.AppPassword
362
+
366
363
spindleMembers, err := db.GetSpindleMembers(
367
364
o.db,
368
365
db.FilterEq("instance", "spindle.tangled.sh"),
···
378
375
return
379
376
}
380
377
381
-
log.Printf("adding %s to default spindle", did)
382
-
session, err := o.createAppPasswordSession()
383
-
if err != nil {
384
-
log.Printf("failed to create session: %s", err)
385
-
return
386
-
}
378
+
// TODO: hardcoded tangled handle and did for now
379
+
tangledHandle := "tangled.sh"
380
+
tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli"
387
381
388
-
record := tangled.SpindleMember{
389
-
LexiconTypeID: "sh.tangled.spindle.member",
390
-
Subject: did,
391
-
Instance: defaultSpindle,
392
-
CreatedAt: time.Now().Format(time.RFC3339),
393
-
}
394
-
395
-
if err := session.putRecord(record); err != nil {
396
-
log.Printf("failed to add member to default knot: %s", err)
382
+
if appPassword == "" {
383
+
log.Println("no app password configured, skipping spindle member addition")
397
384
return
398
385
}
399
386
400
-
log.Printf("successfully added %s to default spindle", did)
401
-
}
402
-
403
-
func (o *OAuthHandler) addToDefaultKnot(did string) {
404
-
// use the tangled.sh app password to get an accessJwt
405
-
// and create an sh.tangled.spindle.member record with that
387
+
log.Printf("adding %s to default spindle", did)
406
388
407
-
allKnots, err := o.enforcer.GetKnotsForUser(did)
389
+
resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid)
408
390
if err != nil {
409
-
log.Printf("failed to get knot members for did %s: %v", did, err)
391
+
log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
410
392
return
411
393
}
412
394
413
-
if slices.Contains(allKnots, defaultKnot) {
414
-
log.Printf("did %s is already a member of the default knot", did)
415
-
return
416
-
}
417
-
418
-
log.Printf("adding %s to default knot", did)
419
-
session, err := o.createAppPasswordSession()
420
-
if err != nil {
421
-
log.Printf("failed to create session: %s", err)
422
-
return
423
-
}
424
-
425
-
record := tangled.KnotMember{
426
-
LexiconTypeID: "sh.tangled.knot.member",
427
-
Subject: did,
428
-
Domain: defaultKnot,
429
-
CreatedAt: time.Now().Format(time.RFC3339),
430
-
}
431
-
432
-
if err := session.putRecord(record); err != nil {
433
-
log.Printf("failed to add member to default knot: %s", err)
434
-
return
435
-
}
436
-
437
-
log.Printf("successfully added %s to default Knot", did)
438
-
}
439
-
440
-
// create a session using apppasswords
441
-
type session struct {
442
-
AccessJwt string `json:"accessJwt"`
443
-
PdsEndpoint string
444
-
}
445
-
446
-
func (o *OAuthHandler) createAppPasswordSession() (*session, error) {
447
-
appPassword := o.config.Core.AppPassword
448
-
if appPassword == "" {
449
-
return nil, fmt.Errorf("no app password configured, skipping member addition")
450
-
}
451
-
452
-
resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid)
453
-
if err != nil {
454
-
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
455
-
}
456
-
457
395
pdsEndpoint := resolved.PDSEndpoint()
458
396
if pdsEndpoint == "" {
459
-
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
397
+
log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
398
+
return
460
399
}
461
400
462
401
sessionPayload := map[string]string{
···
465
404
}
466
405
sessionBytes, err := json.Marshal(sessionPayload)
467
406
if err != nil {
468
-
return nil, fmt.Errorf("failed to marshal session payload: %v", err)
407
+
log.Printf("failed to marshal session payload: %v", err)
408
+
return
469
409
}
470
410
471
411
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
472
412
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
473
413
if err != nil {
474
-
return nil, fmt.Errorf("failed to create session request: %v", err)
414
+
log.Printf("failed to create session request: %v", err)
415
+
return
475
416
}
476
417
sessionReq.Header.Set("Content-Type", "application/json")
477
418
478
419
client := &http.Client{Timeout: 30 * time.Second}
479
420
sessionResp, err := client.Do(sessionReq)
480
421
if err != nil {
481
-
return nil, fmt.Errorf("failed to create session: %v", err)
422
+
log.Printf("failed to create session: %v", err)
423
+
return
482
424
}
483
425
defer sessionResp.Body.Close()
484
426
485
427
if sessionResp.StatusCode != http.StatusOK {
486
-
return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
428
+
log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode)
429
+
return
487
430
}
488
431
489
-
var session session
432
+
var session struct {
433
+
AccessJwt string `json:"accessJwt"`
434
+
}
490
435
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
491
-
return nil, fmt.Errorf("failed to decode session response: %v", err)
436
+
log.Printf("failed to decode session response: %v", err)
437
+
return
492
438
}
493
439
494
-
session.PdsEndpoint = pdsEndpoint
495
-
496
-
return &session, nil
497
-
}
440
+
record := tangled.SpindleMember{
441
+
LexiconTypeID: "sh.tangled.spindle.member",
442
+
Subject: did,
443
+
Instance: defaultSpindle,
444
+
CreatedAt: time.Now().Format(time.RFC3339),
445
+
}
498
446
499
-
func (s *session) putRecord(record any) error {
500
447
recordBytes, err := json.Marshal(record)
501
448
if err != nil {
502
-
return fmt.Errorf("failed to marshal knot member record: %w", err)
449
+
log.Printf("failed to marshal spindle member record: %v", err)
450
+
return
503
451
}
504
452
505
-
payload := map[string]any{
453
+
payload := map[string]interface{}{
506
454
"repo": tangledDid,
507
-
"collection": tangled.KnotMemberNSID,
455
+
"collection": tangled.SpindleMemberNSID,
508
456
"rkey": tid.TID(),
509
457
"record": json.RawMessage(recordBytes),
510
458
}
511
459
512
460
payloadBytes, err := json.Marshal(payload)
513
461
if err != nil {
514
-
return fmt.Errorf("failed to marshal request payload: %w", err)
462
+
log.Printf("failed to marshal request payload: %v", err)
463
+
return
515
464
}
516
465
517
-
url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
466
+
url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
518
467
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
519
468
if err != nil {
520
-
return fmt.Errorf("failed to create HTTP request: %w", err)
469
+
log.Printf("failed to create HTTP request: %v", err)
470
+
return
521
471
}
522
472
523
473
req.Header.Set("Content-Type", "application/json")
524
-
req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
474
+
req.Header.Set("Authorization", "Bearer "+session.AccessJwt)
525
475
526
-
client := &http.Client{Timeout: 30 * time.Second}
527
476
resp, err := client.Do(req)
528
477
if err != nil {
529
-
return fmt.Errorf("failed to add user to default Knot: %w", err)
478
+
log.Printf("failed to add user to default spindle: %v", err)
479
+
return
530
480
}
531
481
defer resp.Body.Close()
532
482
533
483
if resp.StatusCode != http.StatusOK {
534
-
return fmt.Errorf("failed to add user to default Knot: HTTP %d", resp.StatusCode)
484
+
log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode)
485
+
return
535
486
}
536
487
537
-
return nil
488
+
log.Printf("successfully added %s to default spindle", did)
489
+
}
490
+
491
+
func (o *OAuthHandler) addToDefaultKnot(did string) {
492
+
defaultKnot := "knot1.tangled.sh"
493
+
494
+
log.Printf("adding %s to default knot", did)
495
+
err := o.enforcer.AddKnotMember(defaultKnot, did)
496
+
if err != nil {
497
+
log.Println("failed to add user to knot1.tangled.sh: ", err)
498
+
return
499
+
}
500
+
err = o.enforcer.E.SavePolicy()
501
+
if err != nil {
502
+
log.Println("failed to add user to knot1.tangled.sh: ", err)
503
+
return
504
+
}
505
+
506
+
secret, err := db.GetRegistrationKey(o.db, defaultKnot)
507
+
if err != nil {
508
+
log.Println("failed to get registration key for knot1.tangled.sh")
509
+
return
510
+
}
511
+
signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.config.Core.Dev)
512
+
resp, err := signedClient.AddMember(did)
513
+
if err != nil {
514
+
log.Println("failed to add user to knot1.tangled.sh: ", err)
515
+
return
516
+
}
517
+
518
+
if resp.StatusCode != http.StatusNoContent {
519
+
log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode)
520
+
return
521
+
}
538
522
}
-3
appview/oauth/oauth.go
-3
appview/oauth/oauth.go
-13
appview/pages/funcmap.go
-13
appview/pages/funcmap.go
···
21
21
"github.com/go-enry/go-enry/v2"
22
22
"tangled.sh/tangled.sh/core/appview/filetree"
23
23
"tangled.sh/tangled.sh/core/appview/pages/markup"
24
-
"tangled.sh/tangled.sh/core/crypto"
25
24
)
26
25
27
26
func (p *Pages) funcMap() template.FuncMap {
···
277
276
},
278
277
"layoutCenter": func() string {
279
278
return "col-span-1 md:col-span-8 lg:col-span-6"
280
-
},
281
-
282
-
"normalizeForHtmlId": func(s string) string {
283
-
// TODO: extend this to handle other cases?
284
-
return strings.ReplaceAll(s, ":", "_")
285
-
},
286
-
"sshFingerprint": func(pubKey string) string {
287
-
fp, err := crypto.SSHFingerprint(pubKey)
288
-
if err != nil {
289
-
return "error"
290
-
}
291
-
return fp
292
279
},
293
280
}
294
281
}
+35
-81
appview/pages/pages.go
+35
-81
appview/pages/pages.go
···
306
306
return p.execute("timeline/timeline", w, params)
307
307
}
308
308
309
-
type UserProfileSettingsParams struct {
310
-
LoggedInUser *oauth.User
311
-
Tabs []map[string]any
312
-
Tab string
313
-
}
314
-
315
-
func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error {
316
-
return p.execute("user/settings/profile", w, params)
317
-
}
318
-
319
-
type UserKeysSettingsParams struct {
309
+
type SettingsParams struct {
320
310
LoggedInUser *oauth.User
321
311
PubKeys []db.PublicKey
322
-
Tabs []map[string]any
323
-
Tab string
324
-
}
325
-
326
-
func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error {
327
-
return p.execute("user/settings/keys", w, params)
328
-
}
329
-
330
-
type UserEmailsSettingsParams struct {
331
-
LoggedInUser *oauth.User
332
312
Emails []db.Email
333
-
Tabs []map[string]any
334
-
Tab string
335
-
}
336
-
337
-
func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
338
-
return p.execute("user/settings/emails", w, params)
339
313
}
340
314
341
-
type KnotBannerParams struct {
342
-
Registrations []db.Registration
343
-
}
344
-
345
-
func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error {
346
-
return p.executePlain("knots/fragments/banner", w, params)
315
+
func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
316
+
return p.execute("settings", w, params)
347
317
}
348
318
349
319
type KnotsParams struct {
···
368
338
}
369
339
370
340
type KnotListingParams struct {
371
-
*db.Registration
341
+
db.Registration
372
342
}
373
343
374
344
func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
375
345
return p.executePlain("knots/fragments/knotListing", w, params)
346
+
}
347
+
348
+
type KnotListingFullParams struct {
349
+
Registrations []db.Registration
350
+
}
351
+
352
+
func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error {
353
+
return p.executePlain("knots/fragments/knotListingFull", w, params)
354
+
}
355
+
356
+
type KnotSecretParams struct {
357
+
Secret string
358
+
}
359
+
360
+
func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error {
361
+
return p.executePlain("knots/fragments/secret", w, params)
376
362
}
377
363
378
364
type SpindlesParams struct {
···
422
408
return p.execute("repo/fork", w, params)
423
409
}
424
410
425
-
type ProfileHomePageParams struct {
411
+
type ProfilePageParams struct {
426
412
LoggedInUser *oauth.User
427
413
Repos []db.Repo
428
414
CollaboratingRepos []db.Repo
···
432
418
}
433
419
434
420
type ProfileCard struct {
435
-
UserDid string
436
-
UserHandle string
437
-
FollowStatus db.FollowStatus
438
-
FollowersCount int
439
-
FollowingCount int
421
+
UserDid string
422
+
UserHandle string
423
+
FollowStatus db.FollowStatus
424
+
Followers int
425
+
Following int
440
426
441
427
Profile *db.Profile
442
428
}
443
429
444
-
func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error {
430
+
func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error {
445
431
return p.execute("user/profile", w, params)
446
432
}
447
433
···
453
439
454
440
func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error {
455
441
return p.execute("user/repos", w, params)
456
-
}
457
-
458
-
type FollowCard struct {
459
-
UserDid string
460
-
FollowStatus db.FollowStatus
461
-
FollowersCount int
462
-
FollowingCount int
463
-
Profile *db.Profile
464
-
}
465
-
466
-
type FollowersPageParams struct {
467
-
LoggedInUser *oauth.User
468
-
Followers []FollowCard
469
-
Card ProfileCard
470
-
}
471
-
472
-
func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error {
473
-
return p.execute("user/followers", w, params)
474
-
}
475
-
476
-
type FollowingPageParams struct {
477
-
LoggedInUser *oauth.User
478
-
Following []FollowCard
479
-
Card ProfileCard
480
-
}
481
-
482
-
func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error {
483
-
return p.execute("user/following", w, params)
484
442
}
485
443
486
444
type FollowFragmentParams struct {
···
539
497
}
540
498
541
499
type RepoIndexParams struct {
542
-
LoggedInUser *oauth.User
543
-
RepoInfo repoinfo.RepoInfo
544
-
Active string
545
-
TagMap map[string][]string
546
-
CommitsTrunc []*object.Commit
547
-
TagsTrunc []*types.TagReference
548
-
BranchesTrunc []types.Branch
549
-
// ForkInfo *types.ForkInfo
500
+
LoggedInUser *oauth.User
501
+
RepoInfo repoinfo.RepoInfo
502
+
Active string
503
+
TagMap map[string][]string
504
+
CommitsTrunc []*object.Commit
505
+
TagsTrunc []*types.TagReference
506
+
BranchesTrunc []types.Branch
507
+
ForkInfo *types.ForkInfo
550
508
HTMLReadme template.HTML
551
509
Raw bool
552
510
EmailToDidOrHandle map[string]string
···
1312
1270
1313
1271
func (p *Pages) Error404(w io.Writer) error {
1314
1272
return p.execute("errors/404", w, nil)
1315
-
}
1316
-
1317
-
func (p *Pages) ErrorKnot404(w io.Writer) error {
1318
-
return p.execute("errors/knot404", w, nil)
1319
1273
}
1320
1274
1321
1275
func (p *Pages) Error503(w io.Writer) error {
+4
-24
appview/pages/templates/errors/404.html
+4
-24
appview/pages/templates/errors/404.html
···
1
1
{{ define "title" }}404 · tangled{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
-
<div class="mb-6">
7
-
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
8
-
{{ i "search-x" "w-8 h-8 text-gray-400 dark:text-gray-500" }}
9
-
</div>
10
-
</div>
11
-
12
-
<div class="space-y-4">
13
-
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
14
-
404 — page not found
15
-
</h1>
16
-
<p class="text-gray-600 dark:text-gray-300">
17
-
The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL.
18
-
</p>
19
-
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
-
<a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
21
-
{{ i "arrow-left" "w-4 h-4" }}
22
-
go back
23
-
</a>
24
-
</div>
25
-
</div>
26
-
</div>
27
-
</div>
4
+
<h1>404 — nothing like that here!</h1>
5
+
<p>
6
+
It seems we couldn't find what you were looking for. Sorry about that!
7
+
</p>
28
8
{{ end }}
+3
-36
appview/pages/templates/errors/500.html
+3
-36
appview/pages/templates/errors/500.html
···
1
1
{{ define "title" }}500 · tangled{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
-
<div class="mb-6">
7
-
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
8
-
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
-
</div>
10
-
</div>
11
-
12
-
<div class="space-y-4">
13
-
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
14
-
500 — internal server error
15
-
</h1>
16
-
<p class="text-gray-600 dark:text-gray-300">
17
-
Something went wrong on our end. We've been notified and are working to fix the issue.
18
-
</p>
19
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200">
20
-
<div class="flex items-center gap-2">
21
-
{{ i "info" "w-4 h-4" }}
22
-
<span class="font-medium">we're on it!</span>
23
-
</div>
24
-
<p class="mt-1">Our team has been automatically notified about this error.</p>
25
-
</div>
26
-
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
27
-
<button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white">
28
-
{{ i "refresh-cw" "w-4 h-4" }}
29
-
try again
30
-
</button>
31
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
32
-
{{ i "home" "w-4 h-4" }}
33
-
back to home
34
-
</a>
35
-
</div>
36
-
</div>
37
-
</div>
38
-
</div>
39
-
{{ end }}
4
+
<h1>500 — something broke!</h1>
5
+
<p>We're working on getting service back up. Hang tight!</p>
6
+
{{ end }}
+5
-28
appview/pages/templates/errors/503.html
+5
-28
appview/pages/templates/errors/503.html
···
1
1
{{ define "title" }}503 · tangled{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
-
<div class="mb-6">
7
-
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
8
-
{{ i "server-off" "w-8 h-8 text-blue-500 dark:text-blue-400" }}
9
-
</div>
10
-
</div>
11
-
12
-
<div class="space-y-4">
13
-
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
14
-
503 — service unavailable
15
-
</h1>
16
-
<p class="text-gray-600 dark:text-gray-300">
17
-
We were unable to reach the knot hosting this repository. The service may be temporarily unavailable.
18
-
</p>
19
-
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
-
<button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white">
21
-
{{ i "refresh-cw" "w-4 h-4" }}
22
-
try again
23
-
</button>
24
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
25
-
{{ i "arrow-left" "w-4 h-4" }}
26
-
back to timeline
27
-
</a>
28
-
</div>
29
-
</div>
30
-
</div>
31
-
</div>
4
+
<h1>503 — unable to reach knot</h1>
5
+
<p>
6
+
We were unable to reach the knot hosting this repository. Try again
7
+
later.
8
+
</p>
32
9
{{ end }}
-28
appview/pages/templates/errors/knot404.html
-28
appview/pages/templates/errors/knot404.html
···
1
-
{{ define "title" }}404 · tangled{{ end }}
2
-
3
-
{{ define "content" }}
4
-
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
-
<div class="mb-6">
7
-
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
8
-
{{ i "book-x" "w-8 h-8 text-orange-500 dark:text-orange-400" }}
9
-
</div>
10
-
</div>
11
-
12
-
<div class="space-y-4">
13
-
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
14
-
404 — repository not found
15
-
</h1>
16
-
<p class="text-gray-600 dark:text-gray-300">
17
-
The repository you were looking for could not be found. The knot serving the repository may be unavailable.
18
-
</p>
19
-
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline">
21
-
{{ i "arrow-left" "w-4 h-4" }}
22
-
back to timeline
23
-
</a>
24
-
</div>
25
-
</div>
26
-
</div>
27
-
</div>
28
-
{{ end }}
+28
-93
appview/pages/templates/knots/dashboard.html
+28
-93
appview/pages/templates/knots/dashboard.html
···
1
-
{{ define "title" }}{{ .Registration.Domain }} · knots{{ end }}
1
+
{{ define "title" }}{{ .Registration.Domain }}{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="px-6 py-4">
5
-
<div class="flex justify-between items-center">
6
-
<h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1>
7
-
<div id="right-side" class="flex gap-2">
8
-
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
9
-
{{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }}
10
-
{{ if .Registration.IsRegistered }}
11
-
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span>
12
-
{{ if $isOwner }}
4
+
<div class="px-6 py-4">
5
+
<div class="flex justify-between items-center">
6
+
<div id="left-side" class="flex gap-2 items-center">
7
+
<h1 class="text-xl font-bold dark:text-white">
8
+
{{ .Registration.Domain }}
9
+
</h1>
10
+
<span class="text-gray-500 text-base">
11
+
{{ template "repo/fragments/shortTimeAgo" .Registration.Created }}
12
+
</span>
13
+
</div>
14
+
<div id="right-side" class="flex gap-2">
15
+
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
16
+
{{ if .Registration.Registered }}
17
+
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span>
13
18
{{ template "knots/fragments/addMemberModal" .Registration }}
19
+
{{ else }}
20
+
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span>
14
21
{{ end }}
15
-
{{ else if .Registration.IsReadOnly }}
16
-
<span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}">
17
-
{{ i "shield-alert" "w-4 h-4" }} read-only
18
-
</span>
19
-
{{ if $isOwner }}
20
-
{{ block "retryButton" .Registration }} {{ end }}
21
-
{{ end }}
22
-
{{ else }}
23
-
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span>
24
-
{{ if $isOwner }}
25
-
{{ block "retryButton" .Registration }} {{ end }}
26
-
{{ end }}
27
-
{{ end }}
28
-
29
-
{{ if $isOwner }}
30
-
{{ block "deleteButton" .Registration }} {{ end }}
31
-
{{ end }}
22
+
</div>
32
23
</div>
24
+
<div id="operation-error" class="dark:text-red-400"></div>
33
25
</div>
34
-
<div id="operation-error" class="dark:text-red-400"></div>
35
-
</div>
36
26
37
-
{{ if .Members }}
38
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
39
-
<div class="flex flex-col gap-2">
40
-
{{ block "member" . }} {{ end }}
41
-
</div>
42
-
</section>
27
+
{{ if .Members }}
28
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
29
+
<div class="flex flex-col gap-2">
30
+
{{ block "knotMember" . }} {{ end }}
31
+
</div>
32
+
</section>
33
+
{{ end }}
43
34
{{ end }}
44
-
{{ end }}
45
-
46
35
47
-
{{ define "member" }}
36
+
{{ define "knotMember" }}
48
37
{{ range .Members }}
49
38
<div>
50
39
<div class="flex justify-between items-center">
···
52
41
{{ template "user/fragments/picHandleLink" . }}
53
42
<span class="ml-2 font-mono text-gray-500">{{.}}</span>
54
43
</div>
55
-
{{ if ne $.LoggedInUser.Did . }}
56
-
{{ block "removeMemberButton" (list $ . ) }} {{ end }}
57
-
{{ end }}
58
44
</div>
59
45
<div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700">
60
46
{{ $repos := index $.Repos . }}
···
67
53
</div>
68
54
{{ else }}
69
55
<div class="text-gray-500 dark:text-gray-400">
70
-
No repositories configured yet.
56
+
No repositories created yet.
71
57
</div>
72
58
{{ end }}
73
59
</div>
74
60
</div>
75
61
{{ end }}
76
62
{{ end }}
77
-
78
-
{{ define "deleteButton" }}
79
-
<button
80
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
81
-
title="Delete knot"
82
-
hx-delete="/knots/{{ .Domain }}"
83
-
hx-swap="outerHTML"
84
-
hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?"
85
-
hx-headers='{"shouldRedirect": "true"}'
86
-
>
87
-
{{ i "trash-2" "w-5 h-5" }}
88
-
<span class="hidden md:inline">delete</span>
89
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
90
-
</button>
91
-
{{ end }}
92
-
93
-
94
-
{{ define "retryButton" }}
95
-
<button
96
-
class="btn gap-2 group"
97
-
title="Retry knot verification"
98
-
hx-post="/knots/{{ .Domain }}/retry"
99
-
hx-swap="none"
100
-
hx-headers='{"shouldRefresh": "true"}'
101
-
>
102
-
{{ i "rotate-ccw" "w-5 h-5" }}
103
-
<span class="hidden md:inline">retry</span>
104
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
105
-
</button>
106
-
{{ end }}
107
-
108
-
109
-
{{ define "removeMemberButton" }}
110
-
{{ $root := index . 0 }}
111
-
{{ $member := index . 1 }}
112
-
{{ $memberHandle := resolve $member }}
113
-
<button
114
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
115
-
title="Remove member"
116
-
hx-post="/knots/{{ $root.Registration.Domain }}/remove"
117
-
hx-swap="none"
118
-
hx-vals='{"member": "{{$member}}" }'
119
-
hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?"
120
-
>
121
-
{{ i "user-minus" "w-4 h-4" }}
122
-
remove
123
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
124
-
</button>
125
-
{{ end }}
126
-
127
-
+7
-6
appview/pages/templates/knots/fragments/addMemberModal.html
+7
-6
appview/pages/templates/knots/fragments/addMemberModal.html
···
1
1
{{ define "knots/fragments/addMemberModal" }}
2
2
<button
3
3
class="btn gap-2 group"
4
-
title="Add member to this knot"
4
+
title="Add member to this spindle"
5
5
popovertarget="add-member-{{ .Id }}"
6
6
popovertargetaction="toggle"
7
7
>
···
20
20
21
21
{{ define "addKnotMemberPopover" }}
22
22
<form
23
-
hx-post="/knots/{{ .Domain }}/add"
23
+
hx-put="/knots/{{ .Domain }}/member"
24
24
hx-indicator="#spinner"
25
25
hx-swap="none"
26
26
class="flex flex-col gap-2"
···
28
28
<label for="member-did-{{ .Id }}" class="uppercase p-0">
29
29
ADD MEMBER
30
30
</label>
31
-
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
31
+
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p>
32
32
<input
33
33
type="text"
34
34
id="member-did-{{ .Id }}"
35
-
name="member"
35
+
name="subject"
36
36
required
37
37
placeholder="@foo.bsky.social"
38
38
/>
39
39
<div class="flex gap-2 pt-2">
40
-
<button
40
+
<button
41
41
type="button"
42
42
popovertarget="add-member-{{ .Id }}"
43
43
popovertargetaction="hide"
···
54
54
</div>
55
55
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
56
56
</form>
57
-
{{ end }}
57
+
{{ end }}
58
+
+25
-57
appview/pages/templates/knots/fragments/knotListing.html
+25
-57
appview/pages/templates/knots/fragments/knotListing.html
···
1
1
{{ define "knots/fragments/knotListing" }}
2
-
<div id="knot-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
3
-
{{ block "knotLeftSide" . }} {{ end }}
4
-
{{ block "knotRightSide" . }} {{ end }}
2
+
<div
3
+
id="knot-{{.Id}}"
4
+
hx-swap-oob="true"
5
+
class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
6
+
{{ block "listLeftSide" . }} {{ end }}
7
+
{{ block "listRightSide" . }} {{ end }}
5
8
</div>
6
9
{{ end }}
7
10
8
-
{{ define "knotLeftSide" }}
9
-
{{ if .Registered }}
10
-
<a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
11
+
{{ define "listLeftSide" }}
12
+
<div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
11
13
{{ i "hard-drive" "w-4 h-4" }}
12
-
<span class="hover:underline">
14
+
{{ if .Registered }}
15
+
<a href="/knots/{{ .Domain }}">
16
+
{{ .Domain }}
17
+
</a>
18
+
{{ else }}
13
19
{{ .Domain }}
14
-
</span>
15
-
<span class="text-gray-500">
16
-
{{ template "repo/fragments/shortTimeAgo" .Created }}
17
-
</span>
18
-
</a>
19
-
{{ else }}
20
-
<div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
21
-
{{ i "hard-drive" "w-4 h-4" }}
22
-
{{ .Domain }}
20
+
{{ end }}
23
21
<span class="text-gray-500">
24
22
{{ template "repo/fragments/shortTimeAgo" .Created }}
25
23
</span>
26
24
</div>
27
-
{{ end }}
28
25
{{ end }}
29
26
30
-
{{ define "knotRightSide" }}
27
+
{{ define "listRightSide" }}
31
28
<div id="right-side" class="flex gap-2">
32
29
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
33
-
{{ if .IsRegistered }}
34
-
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">
35
-
{{ i "shield-check" "w-4 h-4" }} verified
36
-
</span>
30
+
{{ if .Registered }}
31
+
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span>
37
32
{{ template "knots/fragments/addMemberModal" . }}
38
-
{{ block "knotDeleteButton" . }} {{ end }}
39
-
{{ else if .IsReadOnly }}
40
-
<span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}">
41
-
{{ i "shield-alert" "w-4 h-4" }} read-only
42
-
</span>
43
-
{{ block "knotRetryButton" . }} {{ end }}
44
-
{{ block "knotDeleteButton" . }} {{ end }}
45
33
{{ else }}
46
-
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">
47
-
{{ i "shield-off" "w-4 h-4" }} unverified
48
-
</span>
49
-
{{ block "knotRetryButton" . }} {{ end }}
50
-
{{ block "knotDeleteButton" . }} {{ end }}
34
+
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span>
35
+
{{ block "initializeButton" . }} {{ end }}
51
36
{{ end }}
52
37
</div>
53
38
{{ end }}
54
39
55
-
{{ define "knotDeleteButton" }}
40
+
{{ define "initializeButton" }}
56
41
<button
57
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
58
-
title="Delete knot"
59
-
hx-delete="/knots/{{ .Domain }}"
60
-
hx-swap="outerHTML"
61
-
hx-target="#knot-{{.Id}}"
62
-
hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?"
42
+
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group"
43
+
hx-post="/knots/{{ .Domain }}/init"
44
+
hx-swap="none"
63
45
>
64
-
{{ i "trash-2" "w-5 h-5" }}
65
-
<span class="hidden md:inline">delete</span>
46
+
{{ i "square-play" "w-5 h-5" }}
47
+
<span class="hidden md:inline">initialize</span>
66
48
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
67
49
</button>
68
50
{{ end }}
69
51
70
-
71
-
{{ define "knotRetryButton" }}
72
-
<button
73
-
class="btn gap-2 group"
74
-
title="Retry knot verification"
75
-
hx-post="/knots/{{ .Domain }}/retry"
76
-
hx-swap="none"
77
-
hx-target="#knot-{{.Id}}"
78
-
>
79
-
{{ i "rotate-ccw" "w-5 h-5" }}
80
-
<span class="hidden md:inline">retry</span>
81
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
82
-
</button>
83
-
{{ end }}
+18
appview/pages/templates/knots/fragments/knotListingFull.html
+18
appview/pages/templates/knots/fragments/knotListingFull.html
···
1
+
{{ define "knots/fragments/knotListingFull" }}
2
+
<section
3
+
id="knot-listing-full"
4
+
hx-swap-oob="true"
5
+
class="rounded w-full flex flex-col gap-2">
6
+
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2>
7
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full">
8
+
{{ range $knot := .Registrations }}
9
+
{{ template "knots/fragments/knotListing" . }}
10
+
{{ else }}
11
+
<div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500">
12
+
no knots registered yet
13
+
</div>
14
+
{{ end }}
15
+
</div>
16
+
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
17
+
</section>
18
+
{{ end }}
+10
appview/pages/templates/knots/fragments/secret.html
+10
appview/pages/templates/knots/fragments/secret.html
···
1
+
{{ define "knots/fragments/secret" }}
2
+
<div
3
+
id="secret"
4
+
hx-swap-oob="true"
5
+
class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded px-6 py-2 w-full lg:w-3xl">
6
+
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">generated secret</h2>
7
+
<p class="pb-2">Configure your knot to use this secret, and then hit initialize.</p>
8
+
<span class="font-mono overflow-x">{{ .Secret }}</span>
9
+
</div>
10
+
{{ end }}
+8
-23
appview/pages/templates/knots/index.html
+8
-23
appview/pages/templates/knots/index.html
···
8
8
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
9
9
<div class="flex flex-col gap-6">
10
10
{{ block "about" . }} {{ end }}
11
-
{{ block "list" . }} {{ end }}
11
+
{{ template "knots/fragments/knotListingFull" . }}
12
12
{{ block "register" . }} {{ end }}
13
13
</div>
14
14
</section>
···
27
27
</section>
28
28
{{ end }}
29
29
30
-
{{ define "list" }}
31
-
<section class="rounded w-full flex flex-col gap-2">
32
-
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2>
33
-
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full">
34
-
{{ range $registration := .Registrations }}
35
-
{{ template "knots/fragments/knotListing" . }}
36
-
{{ else }}
37
-
<div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500">
38
-
no knots registered yet
39
-
</div>
40
-
{{ end }}
41
-
</div>
42
-
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
43
-
</section>
44
-
{{ end }}
45
-
46
30
{{ define "register" }}
47
-
<section class="rounded w-full lg:w-fit flex flex-col gap-2">
31
+
<section class="rounded max-w-2xl flex flex-col gap-2">
48
32
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2>
49
-
<p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p>
33
+
<p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to generate a key.</p>
50
34
<form
51
-
hx-post="/knots/register"
52
-
class="max-w-2xl mb-2 space-y-4"
35
+
hx-post="/knots/key"
36
+
class="space-y-4"
53
37
hx-indicator="#register-button"
54
38
hx-swap="none"
55
39
>
···
69
53
>
70
54
<span class="inline-flex items-center gap-2">
71
55
{{ i "plus" "w-4 h-4" }}
72
-
register
56
+
generate
73
57
</span>
74
58
<span class="pl-2 hidden group-[.htmx-request]:inline">
75
59
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
···
77
61
</button>
78
62
</div>
79
63
80
-
<div id="register-error" class="error dark:text-red-400"></div>
64
+
<div id="registration-error" class="error dark:text-red-400"></div>
81
65
</form>
82
66
67
+
<div id="secret"></div>
83
68
</section>
84
69
{{ end }}
-7
appview/pages/templates/layouts/topbar.html
-7
appview/pages/templates/layouts/topbar.html
+2
-8
appview/pages/templates/repo/fork.html
+2
-8
appview/pages/templates/repo/fork.html
···
5
5
<p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p>
6
6
</div>
7
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
-
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
8
+
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none">
9
9
<fieldset class="space-y-3">
10
10
<legend class="dark:text-white">Select a knot to fork into</legend>
11
11
<div class="space-y-2">
···
30
30
</fieldset>
31
31
32
32
<div class="space-y-2">
33
-
<button type="submit" class="btn-create flex items-center gap-2">
34
-
{{ i "git-fork" "w-4 h-4" }}
35
-
fork repo
36
-
<span id="spinner" class="group">
37
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
38
-
</span>
39
-
</button>
33
+
<button type="submit" class="btn">fork repo</button>
40
34
<div id="repo" class="error"></div>
41
35
</div>
42
36
</form>
+47
-22
appview/pages/templates/repo/index.html
+47
-22
appview/pages/templates/repo/index.html
···
84
84
</optgroup>
85
85
</select>
86
86
<div class="flex items-center gap-2">
87
+
{{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }}
88
+
{{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }}
89
+
{{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }}
90
+
{{ $disabled := "" }}
91
+
{{ $title := "" }}
92
+
{{ if eq .ForkInfo.Status 0 }}
93
+
{{ $disabled = "disabled" }}
94
+
{{ $title = "This branch is not behind the upstream" }}
95
+
{{ else if eq .ForkInfo.Status 2 }}
96
+
{{ $disabled = "disabled" }}
97
+
{{ $title = "This branch has conflicts that must be resolved" }}
98
+
{{ else if eq .ForkInfo.Status 3 }}
99
+
{{ $disabled = "disabled" }}
100
+
{{ $title = "This branch does not exist on the upstream" }}
101
+
{{ end }}
102
+
103
+
<button
104
+
id="syncBtn"
105
+
{{ $disabled }}
106
+
{{ if $title }}title="{{ $title }}"{{ end }}
107
+
class="btn flex gap-2 items-center disabled:opacity-50 disabled:cursor-not-allowed"
108
+
hx-post="/{{ .RepoInfo.FullName }}/fork/sync"
109
+
hx-trigger="click"
110
+
hx-swap="none"
111
+
>
112
+
{{ if $disabled }}
113
+
{{ i "refresh-cw-off" "w-4 h-4" }}
114
+
{{ else }}
115
+
{{ i "refresh-cw" "w-4 h-4" }}
116
+
{{ end }}
117
+
<span>sync</span>
118
+
</button>
119
+
{{ end }}
87
120
<a
88
121
href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}"
89
122
class="btn flex items-center gap-2 no-underline hover:no-underline"
···
323
356
324
357
{{ define "repoAfter" }}
325
358
{{- if or .HTMLReadme .Readme -}}
326
-
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
327
-
{{- if .ReadmeFileName -}}
328
-
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
329
-
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
330
-
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
331
-
</div>
332
-
{{- end -}}
333
-
<section
334
-
class="p-6 overflow-auto {{ if not .Raw }}
335
-
prose dark:prose-invert dark:[&_pre]:bg-gray-900
336
-
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
337
-
dark:[&_pre]:border dark:[&_pre]:border-gray-700
338
-
{{ end }}"
339
-
>
340
-
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
341
-
{{- .Readme -}}
342
-
</pre>
343
-
{{- else -}}
344
-
{{ .HTMLReadme }}
345
-
{{- end -}}</article>
346
-
</section>
347
-
</div>
359
+
<section
360
+
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 }}
361
+
prose dark:prose-invert dark:[&_pre]:bg-gray-900
362
+
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
363
+
dark:[&_pre]:border dark:[&_pre]:border-gray-700
364
+
{{ end }}"
365
+
>
366
+
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
367
+
{{- .Readme -}}
368
+
</pre>
369
+
{{- else -}}
370
+
{{ .HTMLReadme }}
371
+
{{- end -}}</article>
372
+
</section>
348
373
{{- end -}}
349
374
{{ end }}
+1
-1
appview/pages/templates/repo/new.html
+1
-1
appview/pages/templates/repo/new.html
···
63
63
<button type="submit" class="btn-create flex items-center gap-2">
64
64
{{ i "book-plus" "w-4 h-4" }}
65
65
create repo
66
-
<span id="spinner" class="group">
66
+
<span id="create-pull-spinner" class="group">
67
67
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
68
68
</span>
69
69
</button>
+1
-3
appview/pages/templates/repo/settings/general.html
+1
-3
appview/pages/templates/repo/settings/general.html
···
8
8
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
9
{{ template "branchSettings" . }}
10
10
{{ template "deleteRepo" . }}
11
-
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
12
11
</div>
13
12
</section>
14
13
{{ end }}
···
23
22
unless you specify a different branch.
24
23
</p>
25
24
</div>
26
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" hx-swap="none" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
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">
27
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">
28
27
<option value="" disabled selected >
29
28
Choose a default branch
···
55
54
<button
56
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"
57
56
type="button"
58
-
hx-swap="none"
59
57
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
60
58
hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?">
61
59
{{ i "trash-2" "size-4" }}
+192
appview/pages/templates/settings.html
+192
appview/pages/templates/settings.html
···
1
+
{{ define "title" }}settings{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="flex flex-col">
8
+
{{ block "profile" . }} {{ end }}
9
+
{{ block "keys" . }} {{ end }}
10
+
{{ block "emails" . }} {{ end }}
11
+
</div>
12
+
{{ end }}
13
+
14
+
{{ define "profile" }}
15
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">profile</h2>
16
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
17
+
<dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200">
18
+
{{ if .LoggedInUser.Handle }}
19
+
<dt class="font-bold">handle</dt>
20
+
<dd>@{{ .LoggedInUser.Handle }}</dd>
21
+
{{ end }}
22
+
<dt class="font-bold">did</dt>
23
+
<dd>{{ .LoggedInUser.Did }}</dd>
24
+
<dt class="font-bold">pds</dt>
25
+
<dd>{{ .LoggedInUser.Pds }}</dd>
26
+
</dl>
27
+
</section>
28
+
{{ end }}
29
+
30
+
{{ define "keys" }}
31
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">ssh keys</h2>
32
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
33
+
<p class="mb-8 dark:text-gray-300">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p>
34
+
<div id="key-list" class="flex flex-col gap-6 mb-8">
35
+
{{ range $index, $key := .PubKeys }}
36
+
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
37
+
<div class="flex flex-col gap-1">
38
+
<div class="inline-flex items-center gap-4">
39
+
{{ i "key" "w-3 h-3 dark:text-gray-300" }}
40
+
<p class="font-bold dark:text-white">{{ .Name }}</p>
41
+
</div>
42
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p>
43
+
<div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full">
44
+
<code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code>
45
+
</div>
46
+
</div>
47
+
<button
48
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
49
+
title="Delete key"
50
+
hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}"
51
+
hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?"
52
+
>
53
+
{{ i "trash-2" "w-5 h-5" }}
54
+
<span class="hidden md:inline">delete</span>
55
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
56
+
</button>
57
+
</div>
58
+
{{ end }}
59
+
</div>
60
+
<form
61
+
hx-put="/settings/keys"
62
+
hx-indicator="#add-sshkey-spinner"
63
+
hx-swap="none"
64
+
class="max-w-2xl mb-8 space-y-4"
65
+
>
66
+
<input
67
+
type="text"
68
+
id="name"
69
+
name="name"
70
+
placeholder="key name"
71
+
required
72
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
73
+
74
+
<input
75
+
id="key"
76
+
name="key"
77
+
placeholder="ssh-rsa AAAAAA..."
78
+
required
79
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
80
+
81
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit">
82
+
<span>add key</span>
83
+
<span id="add-sshkey-spinner" class="group">
84
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
85
+
</span>
86
+
</button>
87
+
88
+
<div id="settings-keys" class="error dark:text-red-400"></div>
89
+
</form>
90
+
</section>
91
+
{{ end }}
92
+
93
+
{{ define "emails" }}
94
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2>
95
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
96
+
<p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p>
97
+
<div id="email-list" class="flex flex-col gap-6 mb-8">
98
+
{{ range $index, $email := .Emails }}
99
+
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
100
+
<div class="flex flex-col gap-2">
101
+
<div class="inline-flex items-center gap-4">
102
+
{{ i "mail" "w-3 h-3 dark:text-gray-300" }}
103
+
<p class="font-bold dark:text-white">{{ .Address }}</p>
104
+
<div class="inline-flex items-center gap-1">
105
+
{{ if .Verified }}
106
+
<span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span>
107
+
{{ else }}
108
+
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span>
109
+
{{ end }}
110
+
{{ if .Primary }}
111
+
<span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span>
112
+
{{ end }}
113
+
</div>
114
+
</div>
115
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p>
116
+
</div>
117
+
<div class="flex gap-2 items-center">
118
+
{{ if not .Verified }}
119
+
<button
120
+
class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
121
+
hx-post="/settings/emails/verify/resend"
122
+
hx-swap="none"
123
+
href="#"
124
+
hx-vals='{"email": "{{ .Address }}"}'>
125
+
{{ i "rotate-cw" "w-5 h-5" }}
126
+
<span class="hidden md:inline">resend</span>
127
+
</button>
128
+
{{ end }}
129
+
{{ if and (not .Primary) .Verified }}
130
+
<a
131
+
class="text-sm dark:text-blue-400 dark:hover:text-blue-300"
132
+
hx-post="/settings/emails/primary"
133
+
hx-swap="none"
134
+
href="#"
135
+
hx-vals='{"email": "{{ .Address }}"}'>
136
+
set as primary
137
+
</a>
138
+
{{ end }}
139
+
{{ if not .Primary }}
140
+
<form
141
+
hx-delete="/settings/emails"
142
+
hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?"
143
+
hx-indicator="#delete-email-{{ $index }}-spinner"
144
+
>
145
+
<input type="hidden" name="email" value="{{ .Address }}">
146
+
<button
147
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center"
148
+
title="Delete email"
149
+
type="submit"
150
+
>
151
+
{{ i "trash-2" "w-5 h-5" }}
152
+
<span class="hidden md:inline">delete</span>
153
+
<span id="delete-email-{{ $index }}-spinner" class="group">
154
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
155
+
</span>
156
+
</button>
157
+
</form>
158
+
{{ end }}
159
+
</div>
160
+
</div>
161
+
{{ end }}
162
+
</div>
163
+
<form
164
+
hx-put="/settings/emails"
165
+
hx-swap="none"
166
+
class="max-w-2xl mb-8 space-y-4"
167
+
hx-indicator="#add-email-spinner"
168
+
>
169
+
<input
170
+
type="email"
171
+
id="email"
172
+
name="email"
173
+
placeholder="your@email.com"
174
+
required
175
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
176
+
>
177
+
178
+
<button
179
+
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center"
180
+
type="submit"
181
+
>
182
+
<span>add email</span>
183
+
<span id="add-email-spinner" class="group">
184
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
185
+
</span>
186
+
</button>
187
+
188
+
<div id="settings-emails-error" class="error dark:text-red-400"></div>
189
+
<div id="settings-emails-success" class="success dark:text-green-400"></div>
190
+
</form>
191
+
</section>
192
+
{{ end }}
+2
-2
appview/pages/templates/spindles/fragments/addMemberModal.html
+2
-2
appview/pages/templates/spindles/fragments/addMemberModal.html
···
14
14
id="add-member-{{ .Instance }}"
15
15
popover
16
16
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
17
-
{{ block "addSpindleMemberPopover" . }} {{ end }}
17
+
{{ block "addMemberPopover" . }} {{ end }}
18
18
</div>
19
19
{{ end }}
20
20
21
-
{{ define "addSpindleMemberPopover" }}
21
+
{{ define "addMemberPopover" }}
22
22
<form
23
23
hx-post="/spindles/{{ .Instance }}/add"
24
24
hx-indicator="#spinner"
+9
-11
appview/pages/templates/spindles/fragments/spindleListing.html
+9
-11
appview/pages/templates/spindles/fragments/spindleListing.html
···
1
1
{{ define "spindles/fragments/spindleListing" }}
2
2
<div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
3
-
{{ block "spindleLeftSide" . }} {{ end }}
4
-
{{ block "spindleRightSide" . }} {{ end }}
3
+
{{ block "leftSide" . }} {{ end }}
4
+
{{ block "rightSide" . }} {{ end }}
5
5
</div>
6
6
{{ end }}
7
7
8
-
{{ define "spindleLeftSide" }}
8
+
{{ define "leftSide" }}
9
9
{{ if .Verified }}
10
10
<a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
11
11
{{ i "hard-drive" "w-4 h-4" }}
12
-
<span class="hover:underline">
13
-
{{ .Instance }}
14
-
</span>
12
+
{{ .Instance }}
15
13
<span class="text-gray-500">
16
14
{{ template "repo/fragments/shortTimeAgo" .Created }}
17
15
</span>
···
27
25
{{ end }}
28
26
{{ end }}
29
27
30
-
{{ define "spindleRightSide" }}
28
+
{{ define "rightSide" }}
31
29
<div id="right-side" class="flex gap-2">
32
30
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
33
31
{{ if .Verified }}
···
35
33
{{ template "spindles/fragments/addMemberModal" . }}
36
34
{{ else }}
37
35
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span>
38
-
{{ block "spindleRetryButton" . }} {{ end }}
36
+
{{ block "retryButton" . }} {{ end }}
39
37
{{ end }}
40
-
{{ block "spindleDeleteButton" . }} {{ end }}
38
+
{{ block "deleteButton" . }} {{ end }}
41
39
</div>
42
40
{{ end }}
43
41
44
-
{{ define "spindleDeleteButton" }}
42
+
{{ define "deleteButton" }}
45
43
<button
46
44
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
47
45
title="Delete spindle"
···
57
55
{{ end }}
58
56
59
57
60
-
{{ define "spindleRetryButton" }}
58
+
{{ define "retryButton" }}
61
59
<button
62
60
class="btn gap-2 group"
63
61
title="Retry spindle verification"
+3
-3
appview/pages/templates/timeline/timeline.html
+3
-3
appview/pages/templates/timeline/timeline.html
···
171
171
{{ end }}
172
172
{{ end }}
173
173
{{ with $stat }}
174
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
174
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
175
175
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
176
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
176
+
<span id="followers">{{ .Followers }} followers</span>
177
177
<span class="select-none after:content-['ยท']"></span>
178
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
178
+
<span id="following">{{ .Following }} following</span>
179
179
</div>
180
180
{{ end }}
181
181
</div>
-30
appview/pages/templates/user/followers.html
-30
appview/pages/templates/user/followers.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }}
2
-
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=followers" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
{{ template "user/fragments/profileCard" .Card }}
14
-
</div>
15
-
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
16
-
{{ block "followers" . }}{{ end }}
17
-
</div>
18
-
</div>
19
-
{{ end }}
20
-
21
-
{{ define "followers" }}
22
-
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
23
-
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
24
-
{{ range .Followers }}
25
-
{{ template "user/fragments/followCard" . }}
26
-
{{ else }}
27
-
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
28
-
{{ end }}
29
-
</div>
30
-
{{ end }}
-30
appview/pages/templates/user/following.html
-30
appview/pages/templates/user/following.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }}
2
-
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=following" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
{{ template "user/fragments/profileCard" .Card }}
14
-
</div>
15
-
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
16
-
{{ block "following" . }}{{ end }}
17
-
</div>
18
-
</div>
19
-
{{ end }}
20
-
21
-
{{ define "following" }}
22
-
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
23
-
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
24
-
{{ range .Following }}
25
-
{{ template "user/fragments/followCard" . }}
26
-
{{ else }}
27
-
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
28
-
{{ end }}
29
-
</div>
30
-
{{ end }}
+2
-2
appview/pages/templates/user/fragments/follow.html
+2
-2
appview/pages/templates/user/fragments/follow.html
···
1
1
{{ define "user/fragments/follow" }}
2
-
<button id="{{ normalizeForHtmlId .UserDid }}"
2
+
<button id="followBtn"
3
3
class="btn mt-2 w-full flex gap-2 items-center group"
4
4
5
5
{{ if eq .FollowStatus.String "IsNotFollowing" }}
···
9
9
{{ end }}
10
10
11
11
hx-trigger="click"
12
-
hx-target="#{{ normalizeForHtmlId .UserDid }}"
12
+
hx-target="#followBtn"
13
13
hx-swap="outerHTML"
14
14
>
15
15
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
-29
appview/pages/templates/user/fragments/followCard.html
-29
appview/pages/templates/user/fragments/followCard.html
···
1
-
{{ define "user/fragments/followCard" }}
2
-
{{ $userIdent := resolve .UserDid }}
3
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
4
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
-
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
7
-
</div>
8
-
9
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
10
-
<a href="/{{ $userIdent }}">
11
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
12
-
</a>
13
-
<p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p>
14
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
15
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
16
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
17
-
<span class="select-none after:content-['ยท']"></span>
18
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
19
-
</div>
20
-
</div>
21
-
22
-
{{ if ne .FollowStatus.String "IsSelf" }}
23
-
<div class="max-w-24">
24
-
{{ template "user/fragments/follow" . }}
25
-
</div>
26
-
{{ end }}
27
-
</div>
28
-
</div>
29
-
{{ end }}
+14
-17
appview/pages/templates/user/fragments/profileCard.html
+14
-17
appview/pages/templates/user/fragments/profileCard.html
···
1
1
{{ define "user/fragments/profileCard" }}
2
-
{{ $userIdent := didOrHandle .UserDid .UserHandle }}
3
2
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
4
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
5
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
···
9
8
</div>
10
9
<div class="col-span-2">
11
10
<div class="flex items-center flex-row flex-nowrap gap-2">
12
-
<p title="{{ $userIdent }}"
11
+
<p title="{{ didOrHandle .UserDid .UserHandle }}"
13
12
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
14
-
{{ $userIdent }}
13
+
{{ didOrHandle .UserDid .UserHandle }}
15
14
</p>
16
-
<a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a>
15
+
<a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a>
17
16
</div>
18
17
19
18
<div class="md:hidden">
20
-
{{ block "followerFollowing" (list . $userIdent) }} {{ end }}
19
+
{{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
21
20
</div>
22
21
</div>
23
22
<div class="col-span-3 md:col-span-full">
···
30
29
{{ end }}
31
30
32
31
<div class="hidden md:block">
33
-
{{ block "followerFollowing" (list $ $userIdent) }} {{ end }}
32
+
{{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }}
34
33
</div>
35
34
36
35
<div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
···
43
42
{{ if .IncludeBluesky }}
44
43
<div class="flex items-center gap-2">
45
44
<span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span>
46
-
<a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a>
45
+
<a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a>
47
46
</div>
48
47
{{ end }}
49
48
{{ range $link := .Links }}
···
89
88
{{ end }}
90
89
91
90
{{ define "followerFollowing" }}
92
-
{{ $root := index . 0 }}
93
-
{{ $userIdent := index . 1 }}
94
-
{{ with $root }}
95
-
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
96
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
97
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
98
-
<span class="select-none after:content-['ยท']"></span>
99
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
100
-
</div>
101
-
{{ end }}
91
+
{{ $followers := index . 0 }}
92
+
{{ $following := index . 1 }}
93
+
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
94
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
95
+
<span id="followers">{{ $followers }} followers</span>
96
+
<span class="select-none after:content-['ยท']"></span>
97
+
<span id="following">{{ $following }} following</span>
98
+
</div>
102
99
{{ end }}
103
100
+1
-1
appview/pages/templates/user/repos.html
+1
-1
appview/pages/templates/user/repos.html
···
3
3
{{ define "extrameta" }}
4
4
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" />
5
5
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" />
6
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/repos" />
7
7
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
8
{{ end }}
9
9
-94
appview/pages/templates/user/settings/emails.html
-94
appview/pages/templates/user/settings/emails.html
···
1
-
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
-
3
-
{{ define "content" }}
4
-
<div class="p-6">
5
-
<p class="text-xl font-bold dark:text-white">Settings</p>
6
-
</div>
7
-
<div class="bg-white dark:bg-gray-800">
8
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
9
-
<div class="col-span-1">
10
-
{{ template "user/settings/fragments/sidebar" . }}
11
-
</div>
12
-
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
-
{{ template "emailSettings" . }}
14
-
</div>
15
-
</section>
16
-
</div>
17
-
{{ end }}
18
-
19
-
{{ define "emailSettings" }}
20
-
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
-
<div class="col-span-1 md:col-span-2">
22
-
<h2 class="text-sm pb-2 uppercase font-bold">Email Addresses</h2>
23
-
<p class="text-gray-500 dark:text-gray-400">
24
-
Commits authored using emails listed here will be associated with your Tangled profile.
25
-
</p>
26
-
</div>
27
-
<div class="col-span-1 md:col-span-1 md:justify-self-end">
28
-
{{ template "addEmailButton" . }}
29
-
</div>
30
-
</div>
31
-
<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">
32
-
{{ range .Emails }}
33
-
{{ template "user/settings/fragments/emailListing" (list $ .) }}
34
-
{{ else }}
35
-
<div class="flex items-center justify-center p-2 text-gray-500">
36
-
no emails added yet
37
-
</div>
38
-
{{ end }}
39
-
</div>
40
-
{{ end }}
41
-
42
-
{{ define "addEmailButton" }}
43
-
<button
44
-
class="btn flex items-center gap-2"
45
-
popovertarget="add-email-modal"
46
-
popovertargetaction="toggle">
47
-
{{ i "plus" "size-4" }}
48
-
add email
49
-
</button>
50
-
<div
51
-
id="add-email-modal"
52
-
popover
53
-
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">
54
-
{{ template "addEmailModal" . }}
55
-
</div>
56
-
{{ end}}
57
-
58
-
{{ define "addEmailModal" }}
59
-
<form
60
-
hx-put="/settings/emails"
61
-
hx-indicator="#spinner"
62
-
hx-swap="none"
63
-
class="flex flex-col gap-2"
64
-
>
65
-
<p class="uppercase p-0">ADD EMAIL</p>
66
-
<p class="text-sm text-gray-500 dark:text-gray-400">Commits using this email will be associated with your profile.</p>
67
-
<input
68
-
type="email"
69
-
id="email-address"
70
-
name="email"
71
-
required
72
-
placeholder="your@email.com"
73
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
74
-
/>
75
-
<div class="flex gap-2 pt-2">
76
-
<button
77
-
type="button"
78
-
popovertarget="add-email-modal"
79
-
popovertargetaction="hide"
80
-
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"
81
-
>
82
-
{{ i "x" "size-4" }} cancel
83
-
</button>
84
-
<button type="submit" class="btn w-1/2 flex items-center">
85
-
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
86
-
<span id="spinner" class="group">
87
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
88
-
</span>
89
-
</button>
90
-
</div>
91
-
<div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div>
92
-
<div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div>
93
-
</form>
94
-
{{ end }}
-62
appview/pages/templates/user/settings/fragments/emailListing.html
-62
appview/pages/templates/user/settings/fragments/emailListing.html
···
1
-
{{ define "user/settings/fragments/emailListing" }}
2
-
{{ $root := index . 0 }}
3
-
{{ $email := index . 1 }}
4
-
<div id="email-{{$email.Address}}" class="flex items-center justify-between p-2">
5
-
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
6
-
<div class="flex items-center gap-2">
7
-
{{ i "mail" "w-4 h-4 text-gray-500 dark:text-gray-400" }}
8
-
<span class="font-bold">
9
-
{{ $email.Address }}
10
-
</span>
11
-
<div class="inline-flex items-center gap-1">
12
-
{{ if $email.Verified }}
13
-
<span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span>
14
-
{{ else }}
15
-
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span>
16
-
{{ end }}
17
-
{{ if $email.Primary }}
18
-
<span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span>
19
-
{{ end }}
20
-
</div>
21
-
</div>
22
-
<div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
23
-
<span>added {{ template "repo/fragments/time" $email.CreatedAt }}</span>
24
-
</div>
25
-
</div>
26
-
<div class="flex gap-2 items-center">
27
-
{{ if not $email.Verified }}
28
-
<button
29
-
class="btn flex gap-2 text-sm px-2 py-1"
30
-
hx-post="/settings/emails/verify/resend"
31
-
hx-swap="none"
32
-
hx-vals='{"email": "{{ $email.Address }}"}'>
33
-
{{ i "rotate-cw" "w-4 h-4" }}
34
-
<span class="hidden md:inline">resend</span>
35
-
</button>
36
-
{{ end }}
37
-
{{ if and (not $email.Primary) $email.Verified }}
38
-
<button
39
-
class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
40
-
hx-post="/settings/emails/primary"
41
-
hx-swap="none"
42
-
hx-vals='{"email": "{{ $email.Address }}"}'>
43
-
set as primary
44
-
</button>
45
-
{{ end }}
46
-
{{ if not $email.Primary }}
47
-
<button
48
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
49
-
title="Delete email"
50
-
hx-delete="/settings/emails"
51
-
hx-swap="none"
52
-
hx-vals='{"email": "{{ $email.Address }}"}'
53
-
hx-confirm="Are you sure you want to delete the email {{ $email.Address }}?"
54
-
>
55
-
{{ i "trash-2" "w-5 h-5" }}
56
-
<span class="hidden md:inline">delete</span>
57
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
58
-
</button>
59
-
{{ end }}
60
-
</div>
61
-
</div>
62
-
{{ end }}
-31
appview/pages/templates/user/settings/fragments/keyListing.html
-31
appview/pages/templates/user/settings/fragments/keyListing.html
···
1
-
{{ define "user/settings/fragments/keyListing" }}
2
-
{{ $root := index . 0 }}
3
-
{{ $key := index . 1 }}
4
-
<div id="key-{{$key.Name}}" class="flex items-center justify-between p-2">
5
-
<div class="hover:no-underline flex flex-col gap-1 text min-w-0 max-w-[80%]">
6
-
<div class="flex items-center gap-2">
7
-
<span>{{ i "key" "w-4" "h-4" }}</span>
8
-
<span class="font-bold">
9
-
{{ $key.Name }}
10
-
</span>
11
-
</div>
12
-
<span class="font-mono text-sm text-gray-500 dark:text-gray-400">
13
-
{{ sshFingerprint $key.Key }}
14
-
</span>
15
-
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
16
-
<span>added {{ template "repo/fragments/time" $key.Created }}</span>
17
-
</div>
18
-
</div>
19
-
<button
20
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
21
-
title="Delete key"
22
-
hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}"
23
-
hx-swap="none"
24
-
hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?"
25
-
>
26
-
{{ i "trash-2" "w-5 h-5" }}
27
-
<span class="hidden md:inline">delete</span>
28
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
29
-
</button>
30
-
</div>
31
-
{{ end }}
-101
appview/pages/templates/user/settings/keys.html
-101
appview/pages/templates/user/settings/keys.html
···
1
-
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
-
3
-
{{ define "content" }}
4
-
<div class="p-6">
5
-
<p class="text-xl font-bold dark:text-white">Settings</p>
6
-
</div>
7
-
<div class="bg-white dark:bg-gray-800">
8
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
9
-
<div class="col-span-1">
10
-
{{ template "user/settings/fragments/sidebar" . }}
11
-
</div>
12
-
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
-
{{ template "sshKeysSettings" . }}
14
-
</div>
15
-
</section>
16
-
</div>
17
-
{{ end }}
18
-
19
-
{{ define "sshKeysSettings" }}
20
-
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
-
<div class="col-span-1 md:col-span-2">
22
-
<h2 class="text-sm pb-2 uppercase font-bold">SSH Keys</h2>
23
-
<p class="text-gray-500 dark:text-gray-400">
24
-
SSH public keys added here will be broadcasted to knots that you are a member of,
25
-
allowing you to push to repositories there.
26
-
</p>
27
-
</div>
28
-
<div class="col-span-1 md:col-span-1 md:justify-self-end">
29
-
{{ template "addKeyButton" . }}
30
-
</div>
31
-
</div>
32
-
<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">
33
-
{{ range .PubKeys }}
34
-
{{ template "user/settings/fragments/keyListing" (list $ .) }}
35
-
{{ else }}
36
-
<div class="flex items-center justify-center p-2 text-gray-500">
37
-
no keys added yet
38
-
</div>
39
-
{{ end }}
40
-
</div>
41
-
{{ end }}
42
-
43
-
{{ define "addKeyButton" }}
44
-
<button
45
-
class="btn flex items-center gap-2"
46
-
popovertarget="add-key-modal"
47
-
popovertargetaction="toggle">
48
-
{{ i "plus" "size-4" }}
49
-
add key
50
-
</button>
51
-
<div
52
-
id="add-key-modal"
53
-
popover
54
-
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">
55
-
{{ template "addKeyModal" . }}
56
-
</div>
57
-
{{ end}}
58
-
59
-
{{ define "addKeyModal" }}
60
-
<form
61
-
hx-put="/settings/keys"
62
-
hx-indicator="#spinner"
63
-
hx-swap="none"
64
-
class="flex flex-col gap-2"
65
-
>
66
-
<p class="uppercase p-0">ADD SSH KEY</p>
67
-
<p class="text-sm text-gray-500 dark:text-gray-400">SSH keys allow you to push to repositories in knots you're a member of.</p>
68
-
<input
69
-
type="text"
70
-
id="key-name"
71
-
name="name"
72
-
required
73
-
placeholder="key name"
74
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
75
-
/>
76
-
<textarea
77
-
type="text"
78
-
id="key-value"
79
-
name="key"
80
-
required
81
-
placeholder="ssh-rsa AAAAB3NzaC1yc2E..."
82
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"></textarea>
83
-
<div class="flex gap-2 pt-2">
84
-
<button
85
-
type="button"
86
-
popovertarget="add-key-modal"
87
-
popovertargetaction="hide"
88
-
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"
89
-
>
90
-
{{ i "x" "size-4" }} cancel
91
-
</button>
92
-
<button type="submit" class="btn w-1/2 flex items-center">
93
-
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
94
-
<span id="spinner" class="group">
95
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
96
-
</span>
97
-
</button>
98
-
</div>
99
-
<div id="settings-keys" class="text-red-500 dark:text-red-400"></div>
100
-
</form>
101
-
{{ end }}
-64
appview/pages/templates/user/settings/profile.html
-64
appview/pages/templates/user/settings/profile.html
···
1
-
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
-
3
-
{{ define "content" }}
4
-
<div class="p-6">
5
-
<p class="text-xl font-bold dark:text-white">Settings</p>
6
-
</div>
7
-
<div class="bg-white dark:bg-gray-800">
8
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
9
-
<div class="col-span-1">
10
-
{{ template "user/settings/fragments/sidebar" . }}
11
-
</div>
12
-
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
-
{{ template "profileInfo" . }}
14
-
</div>
15
-
</section>
16
-
</div>
17
-
{{ end }}
18
-
19
-
{{ define "profileInfo" }}
20
-
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
-
<div class="col-span-1 md:col-span-2">
22
-
<h2 class="text-sm pb-2 uppercase font-bold">Profile</h2>
23
-
<p class="text-gray-500 dark:text-gray-400">
24
-
Your account information from your AT Protocol identity.
25
-
</p>
26
-
</div>
27
-
<div class="col-span-1 md:col-span-1 md:justify-self-end">
28
-
</div>
29
-
</div>
30
-
<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">
31
-
<div class="flex items-center justify-between p-4">
32
-
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
33
-
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
34
-
<span>Handle</span>
35
-
</div>
36
-
{{ if .LoggedInUser.Handle }}
37
-
<span class="font-bold">
38
-
@{{ .LoggedInUser.Handle }}
39
-
</span>
40
-
{{ end }}
41
-
</div>
42
-
</div>
43
-
<div class="flex items-center justify-between p-4">
44
-
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
45
-
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
46
-
<span>Decentralized Identifier (DID)</span>
47
-
</div>
48
-
<span class="font-mono font-bold">
49
-
{{ .LoggedInUser.Did }}
50
-
</span>
51
-
</div>
52
-
</div>
53
-
<div class="flex items-center justify-between p-4">
54
-
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
55
-
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
56
-
<span>Personal Data Server (PDS)</span>
57
-
</div>
58
-
<span class="font-bold">
59
-
{{ .LoggedInUser.Pds }}
60
-
</span>
61
-
</div>
62
-
</div>
63
-
</div>
64
-
{{ end }}
+92
-113
appview/pulls/pulls.go
+92
-113
appview/pulls/pulls.go
···
2
2
3
3
import (
4
4
"database/sql"
5
+
"encoding/json"
5
6
"errors"
6
7
"fmt"
8
+
"io"
7
9
"log"
8
10
"net/http"
9
11
"sort"
···
19
21
"tangled.sh/tangled.sh/core/appview/pages"
20
22
"tangled.sh/tangled.sh/core/appview/pages/markup"
21
23
"tangled.sh/tangled.sh/core/appview/reporesolver"
22
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
23
24
"tangled.sh/tangled.sh/core/idresolver"
24
25
"tangled.sh/tangled.sh/core/knotclient"
25
26
"tangled.sh/tangled.sh/core/patchutil"
···
29
30
"github.com/bluekeyes/go-gitdiff/gitdiff"
30
31
comatproto "github.com/bluesky-social/indigo/api/atproto"
31
32
lexutil "github.com/bluesky-social/indigo/lex/util"
32
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
33
33
"github.com/go-chi/chi/v5"
34
34
"github.com/google/uuid"
35
35
)
···
96
96
return
97
97
}
98
98
99
-
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
99
+
mergeCheckResponse := s.mergeCheck(f, pull, stack)
100
100
resubmitResult := pages.Unknown
101
101
if user.Did == pull.OwnerDid {
102
102
resubmitResult = s.resubmitCheck(f, pull, stack)
···
151
151
}
152
152
}
153
153
154
-
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
154
+
mergeCheckResponse := s.mergeCheck(f, pull, stack)
155
155
resubmitResult := pages.Unknown
156
156
if user != nil && user.Did == pull.OwnerDid {
157
157
resubmitResult = s.resubmitCheck(f, pull, stack)
···
215
215
})
216
216
}
217
217
218
-
func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
218
+
func (s *Pulls) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
219
219
if pull.State == db.PullMerged {
220
220
return types.MergeCheckResponse{}
221
221
}
222
222
223
-
scheme := "https"
224
-
if s.config.Core.Dev {
225
-
scheme = "http"
223
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
224
+
if err != nil {
225
+
log.Printf("failed to get registration key: %v", err)
226
+
return types.MergeCheckResponse{
227
+
Error: "failed to check merge status: this knot is unregistered",
228
+
}
226
229
}
227
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
228
230
229
-
xrpcc := indigoxrpc.Client{
230
-
Host: host,
231
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
232
+
if err != nil {
233
+
log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
234
+
return types.MergeCheckResponse{
235
+
Error: "failed to check merge status",
236
+
}
231
237
}
232
238
233
239
patch := pull.LatestPatch()
···
240
246
patch = mergeable.CombinedPatch()
241
247
}
242
248
243
-
resp, xe := tangled.RepoMergeCheck(
244
-
r.Context(),
245
-
&xrpcc,
246
-
&tangled.RepoMergeCheck_Input{
247
-
Did: f.OwnerDid(),
248
-
Name: f.Name,
249
-
Branch: pull.TargetBranch,
250
-
Patch: patch,
251
-
},
252
-
)
253
-
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
254
-
log.Println("failed to check for mergeability", "err", err)
249
+
resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch)
250
+
if err != nil {
251
+
log.Println("failed to check for mergeability:", err)
255
252
return types.MergeCheckResponse{
256
-
Error: fmt.Sprintf("failed to check merge status: %s", err.Error()),
253
+
Error: "failed to check merge status",
257
254
}
258
255
}
259
-
260
-
// convert xrpc response to internal types
261
-
conflicts := make([]types.ConflictInfo, len(resp.Conflicts))
262
-
for i, conflict := range resp.Conflicts {
263
-
conflicts[i] = types.ConflictInfo{
264
-
Filename: conflict.Filename,
265
-
Reason: conflict.Reason,
256
+
switch resp.StatusCode {
257
+
case 404:
258
+
return types.MergeCheckResponse{
259
+
Error: "failed to check merge status: this knot does not support PRs",
266
260
}
267
-
}
268
-
269
-
result := types.MergeCheckResponse{
270
-
IsConflicted: resp.Is_conflicted,
271
-
Conflicts: conflicts,
261
+
case 400:
262
+
return types.MergeCheckResponse{
263
+
Error: "failed to check merge status: does this knot support PRs?",
264
+
}
272
265
}
273
266
274
-
if resp.Message != nil {
275
-
result.Message = *resp.Message
267
+
respBody, err := io.ReadAll(resp.Body)
268
+
if err != nil {
269
+
log.Println("failed to read merge check response body")
270
+
return types.MergeCheckResponse{
271
+
Error: "failed to check merge status: knot is not speaking the right language",
272
+
}
276
273
}
274
+
defer resp.Body.Close()
277
275
278
-
if resp.Error != nil {
279
-
result.Error = *resp.Error
276
+
var mergeCheckResponse types.MergeCheckResponse
277
+
err = json.Unmarshal(respBody, &mergeCheckResponse)
278
+
if err != nil {
279
+
log.Println("failed to unmarshal merge check response", err)
280
+
return types.MergeCheckResponse{
281
+
Error: "failed to check merge status: knot is not speaking the right language",
282
+
}
280
283
}
281
284
282
-
return result
285
+
return mergeCheckResponse
283
286
}
284
287
285
288
func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
···
864
867
return
865
868
}
866
869
867
-
client, err := s.oauth.ServiceClient(
868
-
r,
869
-
oauth.WithService(fork.Knot),
870
-
oauth.WithLxm(tangled.RepoHiddenRefNSID),
871
-
oauth.WithDev(s.config.Core.Dev),
872
-
)
870
+
secret, err := db.GetRegistrationKey(s.db, fork.Knot)
873
871
if err != nil {
874
-
log.Printf("failed to connect to knot server: %v", err)
872
+
log.Println("failed to fetch registration key:", err)
873
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
874
+
return
875
+
}
876
+
877
+
sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev)
878
+
if err != nil {
879
+
log.Println("failed to create signed client:", err)
875
880
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
876
881
return
877
882
}
···
883
888
return
884
889
}
885
890
886
-
resp, err := tangled.RepoHiddenRef(
887
-
r.Context(),
888
-
client,
889
-
&tangled.RepoHiddenRef_Input{
890
-
ForkRef: sourceBranch,
891
-
RemoteRef: targetBranch,
892
-
Repo: fork.RepoAt().String(),
893
-
},
894
-
)
895
-
if err := xrpcclient.HandleXrpcErr(err); err != nil {
896
-
s.pages.Notice(w, "pull", err.Error())
891
+
resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
892
+
if err != nil {
893
+
log.Println("failed to create hidden ref:", err, resp.StatusCode)
894
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
897
895
return
898
896
}
899
897
900
-
if !resp.Success {
901
-
errorMsg := "Failed to create pull request"
902
-
if resp.Error != nil {
903
-
errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error)
904
-
}
905
-
s.pages.Notice(w, "pull", errorMsg)
898
+
switch resp.StatusCode {
899
+
case 404:
900
+
case 400:
901
+
s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
906
902
return
907
903
}
908
904
···
1468
1464
return
1469
1465
}
1470
1466
1471
-
// update the hidden tracking branch to latest
1472
-
client, err := s.oauth.ServiceClient(
1473
-
r,
1474
-
oauth.WithService(forkRepo.Knot),
1475
-
oauth.WithLxm(tangled.RepoHiddenRefNSID),
1476
-
oauth.WithDev(s.config.Core.Dev),
1477
-
)
1467
+
secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1478
1468
if err != nil {
1479
-
log.Printf("failed to connect to knot server: %v", err)
1469
+
log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1470
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1480
1471
return
1481
1472
}
1482
1473
1483
-
resp, err := tangled.RepoHiddenRef(
1484
-
r.Context(),
1485
-
client,
1486
-
&tangled.RepoHiddenRef_Input{
1487
-
ForkRef: pull.PullSource.Branch,
1488
-
RemoteRef: pull.TargetBranch,
1489
-
Repo: forkRepo.RepoAt().String(),
1490
-
},
1491
-
)
1492
-
if err := xrpcclient.HandleXrpcErr(err); err != nil {
1493
-
s.pages.Notice(w, "resubmit-error", err.Error())
1474
+
// update the hidden tracking branch to latest
1475
+
signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev)
1476
+
if err != nil {
1477
+
log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1478
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1494
1479
return
1495
1480
}
1496
-
if !resp.Success {
1497
-
log.Println("Failed to update tracking ref.", "err", resp.Error)
1498
-
s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.")
1481
+
1482
+
resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1483
+
if err != nil || resp.StatusCode != http.StatusNoContent {
1484
+
log.Printf("failed to update tracking branch: %s", err)
1485
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1499
1486
return
1500
1487
}
1501
1488
···
1921
1908
1922
1909
patch := pullsToMerge.CombinedPatch()
1923
1910
1911
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
1912
+
if err != nil {
1913
+
log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1914
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1915
+
return
1916
+
}
1917
+
1924
1918
ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid)
1925
1919
if err != nil {
1926
1920
log.Printf("resolving identity: %s", err)
···
1933
1927
log.Printf("failed to get primary email: %s", err)
1934
1928
}
1935
1929
1936
-
authorName := ident.Handle.String()
1937
-
mergeInput := &tangled.RepoMerge_Input{
1938
-
Did: f.OwnerDid(),
1939
-
Name: f.Name,
1940
-
Branch: pull.TargetBranch,
1941
-
Patch: patch,
1942
-
CommitMessage: &pull.Title,
1943
-
AuthorName: &authorName,
1944
-
}
1945
-
1946
-
if pull.Body != "" {
1947
-
mergeInput.CommitBody = &pull.Body
1948
-
}
1949
-
1950
-
if email.Address != "" {
1951
-
mergeInput.AuthorEmail = &email.Address
1930
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
1931
+
if err != nil {
1932
+
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1933
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1934
+
return
1952
1935
}
1953
1936
1954
-
client, err := s.oauth.ServiceClient(
1955
-
r,
1956
-
oauth.WithService(f.Knot),
1957
-
oauth.WithLxm(tangled.RepoMergeNSID),
1958
-
oauth.WithDev(s.config.Core.Dev),
1959
-
)
1937
+
// Merge the pull request
1938
+
resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1960
1939
if err != nil {
1961
-
log.Printf("failed to connect to knot server: %v", err)
1940
+
log.Printf("failed to merge pull request: %s", err)
1962
1941
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1963
1942
return
1964
1943
}
1965
1944
1966
-
err = tangled.RepoMerge(r.Context(), client, mergeInput)
1967
-
if err := xrpcclient.HandleXrpcErr(err); err != nil {
1968
-
s.pages.Notice(w, "pull-merge-error", err.Error())
1945
+
if resp.StatusCode != http.StatusOK {
1946
+
log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1947
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1969
1948
return
1970
1949
}
1971
1950
+100
-10
appview/repo/index.go
+100
-10
appview/repo/index.go
···
1
1
package repo
2
2
3
3
import (
4
+
"encoding/json"
5
+
"fmt"
4
6
"log"
5
7
"net/http"
6
8
"slices"
···
9
11
10
12
"tangled.sh/tangled.sh/core/appview/commitverify"
11
13
"tangled.sh/tangled.sh/core/appview/db"
14
+
"tangled.sh/tangled.sh/core/appview/oauth"
12
15
"tangled.sh/tangled.sh/core/appview/pages"
16
+
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
13
17
"tangled.sh/tangled.sh/core/appview/reporesolver"
14
18
"tangled.sh/tangled.sh/core/knotclient"
15
19
"tangled.sh/tangled.sh/core/types"
···
101
105
user := rp.oauth.GetUser(r)
102
106
repoInfo := f.RepoInfo(user)
103
107
108
+
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
109
+
if err != nil {
110
+
log.Printf("failed to get registration key for %s: %s", f.Knot, err)
111
+
rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
112
+
}
113
+
114
+
signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
115
+
if err != nil {
116
+
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
117
+
return
118
+
}
119
+
120
+
var forkInfo *types.ForkInfo
121
+
if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) {
122
+
forkInfo, err = getForkInfo(repoInfo, rp, f, result.Ref, user, signedClient)
123
+
if err != nil {
124
+
log.Printf("Failed to fetch fork information: %v", err)
125
+
return
126
+
}
127
+
}
128
+
104
129
// TODO: a bit dirty
105
-
languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "")
130
+
languageInfo, err := rp.getLanguageInfo(f, signedClient, result.Ref, ref == "")
106
131
if err != nil {
107
132
log.Printf("failed to compute language percentages: %s", err)
108
133
// non-fatal
···
119
144
}
120
145
121
146
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
122
-
LoggedInUser: user,
123
-
RepoInfo: repoInfo,
124
-
TagMap: tagMap,
125
-
RepoIndexResponse: *result,
126
-
CommitsTrunc: commitsTrunc,
127
-
TagsTrunc: tagsTrunc,
128
-
// ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands
147
+
LoggedInUser: user,
148
+
RepoInfo: repoInfo,
149
+
TagMap: tagMap,
150
+
RepoIndexResponse: *result,
151
+
CommitsTrunc: commitsTrunc,
152
+
TagsTrunc: tagsTrunc,
153
+
ForkInfo: forkInfo,
129
154
BranchesTrunc: branchesTrunc,
130
155
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
131
156
VerifiedCommits: vc,
···
136
161
137
162
func (rp *Repo) getLanguageInfo(
138
163
f *reporesolver.ResolvedRepo,
139
-
us *knotclient.UnsignedClient,
164
+
signedClient *knotclient.SignedClient,
140
165
currentRef string,
141
166
isDefaultRef bool,
142
167
) ([]types.RepoLanguageDetails, error) {
···
149
174
150
175
if err != nil || langs == nil {
151
176
// non-fatal, fetch langs from ks
152
-
ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef)
177
+
ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, currentRef)
153
178
if err != nil {
154
179
return nil, err
155
180
}
···
206
231
207
232
return languageStats, nil
208
233
}
234
+
235
+
func getForkInfo(
236
+
repoInfo repoinfo.RepoInfo,
237
+
rp *Repo,
238
+
f *reporesolver.ResolvedRepo,
239
+
currentRef string,
240
+
user *oauth.User,
241
+
signedClient *knotclient.SignedClient,
242
+
) (*types.ForkInfo, error) {
243
+
if user == nil {
244
+
return nil, nil
245
+
}
246
+
247
+
forkInfo := types.ForkInfo{
248
+
IsFork: repoInfo.Source != nil,
249
+
Status: types.UpToDate,
250
+
}
251
+
252
+
if !forkInfo.IsFork {
253
+
forkInfo.IsFork = false
254
+
return &forkInfo, nil
255
+
}
256
+
257
+
us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev)
258
+
if err != nil {
259
+
log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot)
260
+
return nil, err
261
+
}
262
+
263
+
result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name)
264
+
if err != nil {
265
+
log.Println("failed to reach knotserver", err)
266
+
return nil, err
267
+
}
268
+
269
+
if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool {
270
+
return branch.Name == currentRef
271
+
}) {
272
+
forkInfo.Status = types.MissingBranch
273
+
return &forkInfo, nil
274
+
}
275
+
276
+
newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, currentRef, currentRef)
277
+
if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent {
278
+
log.Printf("failed to update tracking branch: %s", err)
279
+
return nil, err
280
+
}
281
+
282
+
hiddenRef := fmt.Sprintf("hidden/%s/%s", currentRef, currentRef)
283
+
284
+
var status types.AncestorCheckResponse
285
+
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, currentRef, hiddenRef)
286
+
if err != nil {
287
+
log.Printf("failed to check if fork is ahead/behind: %s", err)
288
+
return nil, err
289
+
}
290
+
291
+
if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil {
292
+
log.Printf("failed to decode fork status: %s", err)
293
+
return nil, err
294
+
}
295
+
296
+
forkInfo.Status = status.Status
297
+
return &forkInfo, nil
298
+
}
+203
-221
appview/repo/repo.go
+203
-221
appview/repo/repo.go
···
17
17
"strings"
18
18
"time"
19
19
20
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
21
-
lexutil "github.com/bluesky-social/indigo/lex/util"
22
20
"tangled.sh/tangled.sh/core/api/tangled"
23
21
"tangled.sh/tangled.sh/core/appview/commitverify"
24
22
"tangled.sh/tangled.sh/core/appview/config"
···
28
26
"tangled.sh/tangled.sh/core/appview/pages"
29
27
"tangled.sh/tangled.sh/core/appview/pages/markup"
30
28
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
-
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
32
29
"tangled.sh/tangled.sh/core/eventconsumer"
33
30
"tangled.sh/tangled.sh/core/idresolver"
34
31
"tangled.sh/tangled.sh/core/knotclient"
···
36
33
"tangled.sh/tangled.sh/core/rbac"
37
34
"tangled.sh/tangled.sh/core/tid"
38
35
"tangled.sh/tangled.sh/core/types"
39
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
40
36
41
37
securejoin "github.com/cyphar/filepath-securejoin"
42
38
"github.com/go-chi/chi/v5"
43
39
"github.com/go-git/go-git/v5/plumbing"
44
40
41
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
45
42
"github.com/bluesky-social/indigo/atproto/syntax"
43
+
lexutil "github.com/bluesky-social/indigo/lex/util"
46
44
)
47
45
48
46
type Repo struct {
···
56
54
enforcer *rbac.Enforcer
57
55
notifier notify.Notifier
58
56
logger *slog.Logger
59
-
serviceAuth *serviceauth.ServiceAuth
60
57
}
61
58
62
59
func New(
···
128
125
129
126
repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page)
130
127
if err != nil {
131
-
rp.pages.Error503(w)
132
128
log.Println("failed to reach knotserver", err)
133
129
return
134
130
}
135
131
136
132
tagResult, err := us.Tags(f.OwnerDid(), f.Name)
137
133
if err != nil {
138
-
rp.pages.Error503(w)
139
134
log.Println("failed to reach knotserver", err)
140
135
return
141
136
}
···
151
146
152
147
branchResult, err := us.Branches(f.OwnerDid(), f.Name)
153
148
if err != nil {
154
-
rp.pages.Error503(w)
155
149
log.Println("failed to reach knotserver", err)
156
150
return
157
151
}
···
318
312
319
313
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref))
320
314
if err != nil {
321
-
rp.pages.Error503(w)
322
315
log.Println("failed to reach knotserver", err)
323
316
return
324
317
}
···
382
375
if !rp.config.Core.Dev {
383
376
protocol = "https"
384
377
}
385
-
386
-
// if the tree path has a trailing slash, let's strip it
387
-
// so we don't 404
388
-
treePath = strings.TrimSuffix(treePath, "/")
389
-
390
378
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath))
391
379
if err != nil {
392
-
rp.pages.Error503(w)
393
380
log.Println("failed to reach knotserver", err)
394
381
return
395
382
}
396
383
397
-
// uhhh so knotserver returns a 500 if the entry isn't found in
398
-
// the requested tree path, so let's stick to not-OK here.
399
-
// we can fix this once we build out the xrpc apis for these operations.
400
-
if resp.StatusCode != http.StatusOK {
401
-
rp.pages.Error404(w)
402
-
return
403
-
}
404
-
405
384
body, err := io.ReadAll(resp.Body)
406
385
if err != nil {
407
386
log.Printf("Error reading response body: %v", err)
···
459
438
460
439
result, err := us.Tags(f.OwnerDid(), f.Name)
461
440
if err != nil {
462
-
rp.pages.Error503(w)
463
441
log.Println("failed to reach knotserver", err)
464
442
return
465
443
}
···
517
495
518
496
result, err := us.Branches(f.OwnerDid(), f.Name)
519
497
if err != nil {
520
-
rp.pages.Error503(w)
521
498
log.Println("failed to reach knotserver", err)
522
499
return
523
500
}
···
547
524
}
548
525
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath))
549
526
if err != nil {
550
-
rp.pages.Error503(w)
551
527
log.Println("failed to reach knotserver", err)
552
-
return
553
-
}
554
-
555
-
if resp.StatusCode == http.StatusNotFound {
556
-
rp.pages.Error404(w)
557
528
return
558
529
}
559
530
···
863
834
fail("Failed to write record to PDS.", err)
864
835
return
865
836
}
866
-
867
-
aturi := resp.Uri
868
-
l = l.With("at-uri", aturi)
837
+
l = l.With("at-uri", resp.Uri)
869
838
l.Info("wrote record to PDS")
870
839
871
-
tx, err := rp.db.BeginTx(r.Context(), nil)
840
+
l.Info("adding to knot")
841
+
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
872
842
if err != nil {
873
-
fail("Failed to add collaborator.", err)
843
+
fail("Failed to add to knot.", err)
874
844
return
875
845
}
876
846
877
-
rollback := func() {
878
-
err1 := tx.Rollback()
879
-
err2 := rp.enforcer.E.LoadPolicy()
880
-
err3 := rollbackRecord(context.Background(), aturi, client)
847
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
848
+
if err != nil {
849
+
fail("Failed to add to knot.", err)
850
+
return
851
+
}
881
852
882
-
// ignore txn complete errors, this is okay
883
-
if errors.Is(err1, sql.ErrTxDone) {
884
-
err1 = nil
885
-
}
853
+
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String())
854
+
if err != nil {
855
+
fail("Knot was unreachable.", err)
856
+
return
857
+
}
886
858
887
-
if errs := errors.Join(err1, err2, err3); errs != nil {
888
-
l.Error("failed to rollback changes", "errs", errs)
889
-
return
859
+
if ksResp.StatusCode != http.StatusNoContent {
860
+
fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil)
861
+
return
862
+
}
863
+
864
+
tx, err := rp.db.BeginTx(r.Context(), nil)
865
+
if err != nil {
866
+
fail("Failed to add collaborator.", err)
867
+
return
868
+
}
869
+
defer func() {
870
+
tx.Rollback()
871
+
err = rp.enforcer.E.LoadPolicy()
872
+
if err != nil {
873
+
fail("Failed to add collaborator.", err)
890
874
}
891
-
}
892
-
defer rollback()
875
+
}()
893
876
894
877
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
895
878
if err != nil {
···
921
904
return
922
905
}
923
906
924
-
// clear aturi to when everything is successful
925
-
aturi = ""
926
-
927
907
rp.pages.HxRefresh(w)
928
908
}
929
909
930
910
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
931
911
user := rp.oauth.GetUser(r)
932
912
933
-
noticeId := "operation-error"
934
913
f, err := rp.repoResolver.Resolve(r)
935
914
if err != nil {
936
915
log.Println("failed to get repo and knot", err)
···
950
929
})
951
930
if err != nil {
952
931
log.Printf("failed to delete record: %s", err)
953
-
rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
932
+
rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
954
933
return
955
934
}
956
935
log.Println("removed repo record ", f.RepoAt().String())
957
936
958
-
client, err := rp.oauth.ServiceClient(
959
-
r,
960
-
oauth.WithService(f.Knot),
961
-
oauth.WithLxm(tangled.RepoDeleteNSID),
962
-
oauth.WithDev(rp.config.Core.Dev),
963
-
)
937
+
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
964
938
if err != nil {
965
-
log.Println("failed to connect to knot server:", err)
939
+
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
966
940
return
967
941
}
968
942
969
-
err = tangled.RepoDelete(
970
-
r.Context(),
971
-
client,
972
-
&tangled.RepoDelete_Input{
973
-
Did: f.OwnerDid(),
974
-
Name: f.Name,
975
-
Rkey: f.Rkey,
976
-
},
977
-
)
978
-
if err := xrpcclient.HandleXrpcErr(err); err != nil {
979
-
rp.pages.Notice(w, noticeId, err.Error())
943
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
944
+
if err != nil {
945
+
log.Println("failed to create client to ", f.Knot)
980
946
return
981
947
}
982
-
log.Println("deleted repo from knot")
948
+
949
+
ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.Name)
950
+
if err != nil {
951
+
log.Printf("failed to make request to %s: %s", f.Knot, err)
952
+
return
953
+
}
954
+
955
+
if ksResp.StatusCode != http.StatusNoContent {
956
+
log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
957
+
} else {
958
+
log.Println("removed repo from knot ", f.Knot)
959
+
}
983
960
984
961
tx, err := rp.db.BeginTx(r.Context(), nil)
985
962
if err != nil {
···
998
975
// remove collaborator RBAC
999
976
repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
1000
977
if err != nil {
1001
-
rp.pages.Notice(w, noticeId, "Failed to remove collaborators")
978
+
rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
1002
979
return
1003
980
}
1004
981
for _, c := range repoCollaborators {
···
1010
987
// remove repo RBAC
1011
988
err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
1012
989
if err != nil {
1013
-
rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
990
+
rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
1014
991
return
1015
992
}
1016
993
1017
994
// remove repo from db
1018
995
err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
1019
996
if err != nil {
1020
-
rp.pages.Notice(w, noticeId, "Failed to update appview")
997
+
rp.pages.Notice(w, "settings-delete", "Failed to update appview")
1021
998
return
1022
999
}
1023
1000
log.Println("removed repo from db")
···
1046
1023
return
1047
1024
}
1048
1025
1049
-
noticeId := "operation-error"
1050
1026
branch := r.FormValue("branch")
1051
1027
if branch == "" {
1052
1028
http.Error(w, "malformed form", http.StatusBadRequest)
1053
1029
return
1054
1030
}
1055
1031
1056
-
client, err := rp.oauth.ServiceClient(
1057
-
r,
1058
-
oauth.WithService(f.Knot),
1059
-
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
1060
-
oauth.WithDev(rp.config.Core.Dev),
1061
-
)
1032
+
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1062
1033
if err != nil {
1063
-
log.Println("failed to connect to knot server:", err)
1064
-
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1034
+
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
1065
1035
return
1066
1036
}
1067
1037
1068
-
xe := tangled.RepoSetDefaultBranch(
1069
-
r.Context(),
1070
-
client,
1071
-
&tangled.RepoSetDefaultBranch_Input{
1072
-
Repo: f.RepoAt().String(),
1073
-
DefaultBranch: branch,
1074
-
},
1075
-
)
1076
-
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1077
-
log.Println("xrpc failed", "err", xe)
1078
-
rp.pages.Notice(w, noticeId, err.Error())
1038
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1039
+
if err != nil {
1040
+
log.Println("failed to create client to ", f.Knot)
1079
1041
return
1080
1042
}
1081
1043
1082
-
rp.pages.HxRefresh(w)
1044
+
ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch)
1045
+
if err != nil {
1046
+
log.Printf("failed to make request to %s: %s", f.Knot, err)
1047
+
return
1048
+
}
1049
+
1050
+
if ksResp.StatusCode != http.StatusNoContent {
1051
+
rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
1052
+
return
1053
+
}
1054
+
1055
+
w.Write(fmt.Append(nil, "default branch set to: ", branch))
1083
1056
}
1084
1057
1085
1058
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
···
1195
1168
case "pipelines":
1196
1169
rp.pipelineSettings(w, r)
1197
1170
}
1171
+
1172
+
// user := rp.oauth.GetUser(r)
1173
+
// repoCollaborators, err := f.Collaborators(r.Context())
1174
+
// if err != nil {
1175
+
// log.Println("failed to get collaborators", err)
1176
+
// }
1177
+
1178
+
// isCollaboratorInviteAllowed := false
1179
+
// if user != nil {
1180
+
// ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
1181
+
// if err == nil && ok {
1182
+
// isCollaboratorInviteAllowed = true
1183
+
// }
1184
+
// }
1185
+
1186
+
// us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1187
+
// if err != nil {
1188
+
// log.Println("failed to create unsigned client", err)
1189
+
// return
1190
+
// }
1191
+
1192
+
// result, err := us.Branches(f.OwnerDid(), f.Name)
1193
+
// if err != nil {
1194
+
// log.Println("failed to reach knotserver", err)
1195
+
// return
1196
+
// }
1197
+
1198
+
// // all spindles that this user is a member of
1199
+
// spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1200
+
// if err != nil {
1201
+
// log.Println("failed to fetch spindles", err)
1202
+
// return
1203
+
// }
1204
+
1205
+
// var secrets []*tangled.RepoListSecrets_Secret
1206
+
// if f.Spindle != "" {
1207
+
// if spindleClient, err := rp.oauth.ServiceClient(
1208
+
// r,
1209
+
// oauth.WithService(f.Spindle),
1210
+
// oauth.WithLxm(tangled.RepoListSecretsNSID),
1211
+
// oauth.WithDev(rp.config.Core.Dev),
1212
+
// ); err != nil {
1213
+
// log.Println("failed to create spindle client", err)
1214
+
// } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1215
+
// log.Println("failed to fetch secrets", err)
1216
+
// } else {
1217
+
// secrets = resp.Secrets
1218
+
// }
1219
+
// }
1220
+
1221
+
// rp.pages.RepoSettings(w, pages.RepoSettingsParams{
1222
+
// LoggedInUser: user,
1223
+
// RepoInfo: f.RepoInfo(user),
1224
+
// Collaborators: repoCollaborators,
1225
+
// IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1226
+
// Branches: result.Branches,
1227
+
// Spindles: spindles,
1228
+
// CurrentSpindle: f.Spindle,
1229
+
// Secrets: secrets,
1230
+
// })
1198
1231
}
1199
1232
1200
1233
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
···
1209
1242
1210
1243
result, err := us.Branches(f.OwnerDid(), f.Name)
1211
1244
if err != nil {
1212
-
rp.pages.Error503(w)
1213
1245
log.Println("failed to reach knotserver", err)
1214
1246
return
1215
1247
}
···
1314
1346
1315
1347
switch r.Method {
1316
1348
case http.MethodPost:
1317
-
client, err := rp.oauth.ServiceClient(
1318
-
r,
1319
-
oauth.WithService(f.Knot),
1320
-
oauth.WithLxm(tangled.RepoForkSyncNSID),
1321
-
oauth.WithDev(rp.config.Core.Dev),
1322
-
)
1349
+
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1323
1350
if err != nil {
1324
-
rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1351
+
rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot))
1325
1352
return
1326
1353
}
1327
1354
1328
-
repoInfo := f.RepoInfo(user)
1329
-
if repoInfo.Source == nil {
1330
-
rp.pages.Notice(w, "repo", "This repository is not a fork.")
1355
+
client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1356
+
if err != nil {
1357
+
rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1331
1358
return
1332
1359
}
1333
1360
1334
-
err = tangled.RepoForkSync(
1335
-
r.Context(),
1336
-
client,
1337
-
&tangled.RepoForkSync_Input{
1338
-
Did: user.Did,
1339
-
Name: f.Name,
1340
-
Source: repoInfo.Source.RepoAt().String(),
1341
-
Branch: ref,
1342
-
},
1343
-
)
1344
-
if err := xrpcclient.HandleXrpcErr(err); err != nil {
1345
-
rp.pages.Notice(w, "repo", err.Error())
1361
+
var uri string
1362
+
if rp.config.Core.Dev {
1363
+
uri = "http"
1364
+
} else {
1365
+
uri = "https"
1366
+
}
1367
+
forkName := fmt.Sprintf("%s", f.Name)
1368
+
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1369
+
1370
+
_, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref)
1371
+
if err != nil {
1372
+
rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
1346
1373
return
1347
1374
}
1348
1375
···
1375
1402
})
1376
1403
1377
1404
case http.MethodPost:
1378
-
l := rp.logger.With("handler", "ForkRepo")
1379
1405
1380
-
targetKnot := r.FormValue("knot")
1381
-
if targetKnot == "" {
1406
+
knot := r.FormValue("knot")
1407
+
if knot == "" {
1382
1408
rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1383
1409
return
1384
1410
}
1385
-
l = l.With("targetKnot", targetKnot)
1386
1411
1387
-
ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create")
1412
+
ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1388
1413
if err != nil || !ok {
1389
1414
rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1390
1415
return
1391
1416
}
1392
1417
1393
-
// choose a name for a fork
1394
-
forkName := f.Name
1418
+
forkName := fmt.Sprintf("%s", f.Name)
1419
+
1395
1420
// this check is *only* to see if the forked repo name already exists
1396
1421
// in the user's account.
1397
1422
existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
···
1407
1432
// repo with this name already exists, append random string
1408
1433
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1409
1434
}
1410
-
l = l.With("forkName", forkName)
1435
+
secret, err := db.GetRegistrationKey(rp.db, knot)
1436
+
if err != nil {
1437
+
rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1438
+
return
1439
+
}
1440
+
1441
+
client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev)
1442
+
if err != nil {
1443
+
rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1444
+
return
1445
+
}
1411
1446
1412
-
uri := "https"
1447
+
var uri string
1413
1448
if rp.config.Core.Dev {
1414
1449
uri = "http"
1450
+
} else {
1451
+
uri = "https"
1415
1452
}
1416
-
1417
1453
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1418
-
l = l.With("cloneUrl", forkSourceUrl)
1419
-
1420
1454
sourceAt := f.RepoAt().String()
1421
1455
1422
-
// create an atproto record for this fork
1423
1456
rkey := tid.TID()
1424
1457
repo := &db.Repo{
1425
1458
Did: user.Did,
1426
1459
Name: forkName,
1427
-
Knot: targetKnot,
1460
+
Knot: knot,
1428
1461
Rkey: rkey,
1429
1462
Source: sourceAt,
1430
1463
}
1431
1464
1465
+
tx, err := rp.db.BeginTx(r.Context(), nil)
1466
+
if err != nil {
1467
+
log.Println(err)
1468
+
rp.pages.Notice(w, "repo", "Failed to save repository information.")
1469
+
return
1470
+
}
1471
+
defer func() {
1472
+
tx.Rollback()
1473
+
err = rp.enforcer.E.LoadPolicy()
1474
+
if err != nil {
1475
+
log.Println("failed to rollback policies")
1476
+
}
1477
+
}()
1478
+
1479
+
resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1480
+
if err != nil {
1481
+
rp.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1482
+
return
1483
+
}
1484
+
1485
+
switch resp.StatusCode {
1486
+
case http.StatusConflict:
1487
+
rp.pages.Notice(w, "repo", "A repository with that name already exists.")
1488
+
return
1489
+
case http.StatusInternalServerError:
1490
+
rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1491
+
case http.StatusNoContent:
1492
+
// continue
1493
+
}
1494
+
1432
1495
xrpcClient, err := rp.oauth.AuthorizedClient(r)
1433
1496
if err != nil {
1434
-
l.Error("failed to create xrpcclient", "err", err)
1435
-
rp.pages.Notice(w, "repo", "Failed to fork repository.")
1497
+
log.Println("failed to get authorized client", err)
1498
+
rp.pages.Notice(w, "repo", "Failed to create repository.")
1436
1499
return
1437
1500
}
1438
1501
···
1451
1514
}},
1452
1515
})
1453
1516
if err != nil {
1454
-
l.Error("failed to write to PDS", "err", err)
1517
+
log.Printf("failed to create record: %s", err)
1455
1518
rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1456
1519
return
1457
1520
}
1458
-
1459
-
aturi := atresp.Uri
1460
-
l = l.With("aturi", aturi)
1461
-
l.Info("wrote to PDS")
1462
-
1463
-
tx, err := rp.db.BeginTx(r.Context(), nil)
1464
-
if err != nil {
1465
-
l.Info("txn failed", "err", err)
1466
-
rp.pages.Notice(w, "repo", "Failed to save repository information.")
1467
-
return
1468
-
}
1469
-
1470
-
// The rollback function reverts a few things on failure:
1471
-
// - the pending txn
1472
-
// - the ACLs
1473
-
// - the atproto record created
1474
-
rollback := func() {
1475
-
err1 := tx.Rollback()
1476
-
err2 := rp.enforcer.E.LoadPolicy()
1477
-
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
1478
-
1479
-
// ignore txn complete errors, this is okay
1480
-
if errors.Is(err1, sql.ErrTxDone) {
1481
-
err1 = nil
1482
-
}
1483
-
1484
-
if errs := errors.Join(err1, err2, err3); errs != nil {
1485
-
l.Error("failed to rollback changes", "errs", errs)
1486
-
return
1487
-
}
1488
-
}
1489
-
defer rollback()
1490
-
1491
-
client, err := rp.oauth.ServiceClient(
1492
-
r,
1493
-
oauth.WithService(targetKnot),
1494
-
oauth.WithLxm(tangled.RepoCreateNSID),
1495
-
oauth.WithDev(rp.config.Core.Dev),
1496
-
)
1497
-
if err != nil {
1498
-
l.Error("could not create service client", "err", err)
1499
-
rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1500
-
return
1501
-
}
1502
-
1503
-
err = tangled.RepoCreate(
1504
-
r.Context(),
1505
-
client,
1506
-
&tangled.RepoCreate_Input{
1507
-
Rkey: rkey,
1508
-
Source: &forkSourceUrl,
1509
-
},
1510
-
)
1511
-
if err := xrpcclient.HandleXrpcErr(err); err != nil {
1512
-
rp.pages.Notice(w, "repo", err.Error())
1513
-
return
1514
-
}
1521
+
log.Println("created repo record: ", atresp.Uri)
1515
1522
1516
1523
err = db.AddRepo(tx, repo)
1517
1524
if err != nil {
···
1522
1529
1523
1530
// acls
1524
1531
p, _ := securejoin.SecureJoin(user.Did, forkName)
1525
-
err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
1532
+
err = rp.enforcer.AddRepo(user.Did, knot, p)
1526
1533
if err != nil {
1527
1534
log.Println(err)
1528
1535
rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
···
1543
1550
return
1544
1551
}
1545
1552
1546
-
// reset the ATURI because the transaction completed successfully
1547
-
aturi = ""
1548
-
1549
-
rp.notifier.NewRepo(r.Context(), repo)
1550
1553
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1551
-
}
1552
-
}
1553
-
1554
-
// this is used to rollback changes made to the PDS
1555
-
//
1556
-
// it is a no-op if the provided ATURI is empty
1557
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
1558
-
if aturi == "" {
1559
-
return nil
1554
+
return
1560
1555
}
1561
-
1562
-
parsed := syntax.ATURI(aturi)
1563
-
1564
-
collection := parsed.Collection().String()
1565
-
repo := parsed.Authority().String()
1566
-
rkey := parsed.RecordKey().String()
1567
-
1568
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
1569
-
Collection: collection,
1570
-
Repo: repo,
1571
-
Rkey: rkey,
1572
-
})
1573
-
return err
1574
1556
}
1575
1557
1576
1558
func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
-164
appview/serververify/verify.go
-164
appview/serververify/verify.go
···
1
-
package serververify
2
-
3
-
import (
4
-
"context"
5
-
"errors"
6
-
"fmt"
7
-
"io"
8
-
"net/http"
9
-
"strings"
10
-
"time"
11
-
12
-
"tangled.sh/tangled.sh/core/appview/db"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
)
15
-
16
-
var (
17
-
FetchError = errors.New("failed to fetch owner")
18
-
)
19
-
20
-
// fetchOwner fetches the owner DID from a server's /owner endpoint
21
-
func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
22
-
scheme := "https"
23
-
if dev {
24
-
scheme = "http"
25
-
}
26
-
27
-
url := fmt.Sprintf("%s://%s/owner", scheme, domain)
28
-
req, err := http.NewRequest("GET", url, nil)
29
-
if err != nil {
30
-
return "", err
31
-
}
32
-
33
-
client := &http.Client{
34
-
Timeout: 1 * time.Second,
35
-
}
36
-
37
-
resp, err := client.Do(req.WithContext(ctx))
38
-
if err != nil || resp.StatusCode != 200 {
39
-
return "", fmt.Errorf("failed to fetch /owner")
40
-
}
41
-
42
-
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
43
-
if err != nil {
44
-
return "", fmt.Errorf("failed to read /owner response: %w", err)
45
-
}
46
-
47
-
did := strings.TrimSpace(string(body))
48
-
if did == "" {
49
-
return "", fmt.Errorf("empty DID in /owner response")
50
-
}
51
-
52
-
return did, nil
53
-
}
54
-
55
-
type OwnerMismatch struct {
56
-
expected string
57
-
observed string
58
-
}
59
-
60
-
func (e *OwnerMismatch) Error() string {
61
-
return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed)
62
-
}
63
-
64
-
// RunVerification verifies that the server at the given domain has the expected owner
65
-
func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error {
66
-
observedOwner, err := fetchOwner(ctx, domain, dev)
67
-
if err != nil {
68
-
return fmt.Errorf("%w: %w", FetchError, err)
69
-
}
70
-
71
-
if observedOwner != expectedOwner {
72
-
return &OwnerMismatch{
73
-
expected: expectedOwner,
74
-
observed: observedOwner,
75
-
}
76
-
}
77
-
78
-
return nil
79
-
}
80
-
81
-
// MarkSpindleVerified marks a spindle as verified in the DB and adds the user as its owner
82
-
func MarkSpindleVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) {
83
-
tx, err := d.Begin()
84
-
if err != nil {
85
-
return 0, fmt.Errorf("failed to create txn: %w", err)
86
-
}
87
-
defer func() {
88
-
tx.Rollback()
89
-
e.E.LoadPolicy()
90
-
}()
91
-
92
-
// mark this spindle as verified in the db
93
-
rowId, err := db.VerifySpindle(
94
-
tx,
95
-
db.FilterEq("owner", owner),
96
-
db.FilterEq("instance", instance),
97
-
)
98
-
if err != nil {
99
-
return 0, fmt.Errorf("failed to write to DB: %w", err)
100
-
}
101
-
102
-
err = e.AddSpindleOwner(instance, owner)
103
-
if err != nil {
104
-
return 0, fmt.Errorf("failed to update ACL: %w", err)
105
-
}
106
-
107
-
err = tx.Commit()
108
-
if err != nil {
109
-
return 0, fmt.Errorf("failed to commit txn: %w", err)
110
-
}
111
-
112
-
err = e.E.SavePolicy()
113
-
if err != nil {
114
-
return 0, fmt.Errorf("failed to update ACL: %w", err)
115
-
}
116
-
117
-
return rowId, nil
118
-
}
119
-
120
-
// MarkKnotVerified marks a knot as verified and sets up ownership/permissions
121
-
func MarkKnotVerified(d *db.DB, e *rbac.Enforcer, domain, owner string) error {
122
-
tx, err := d.BeginTx(context.Background(), nil)
123
-
if err != nil {
124
-
return fmt.Errorf("failed to start tx: %w", err)
125
-
}
126
-
defer func() {
127
-
tx.Rollback()
128
-
e.E.LoadPolicy()
129
-
}()
130
-
131
-
// mark as registered
132
-
err = db.MarkRegistered(
133
-
tx,
134
-
db.FilterEq("did", owner),
135
-
db.FilterEq("domain", domain),
136
-
)
137
-
if err != nil {
138
-
return fmt.Errorf("failed to register domain: %w", err)
139
-
}
140
-
141
-
// add basic acls for this domain
142
-
err = e.AddKnot(domain)
143
-
if err != nil {
144
-
return fmt.Errorf("failed to add knot to enforcer: %w", err)
145
-
}
146
-
147
-
// add this did as owner of this domain
148
-
err = e.AddKnotOwner(domain, owner)
149
-
if err != nil {
150
-
return fmt.Errorf("failed to add knot owner to enforcer: %w", err)
151
-
}
152
-
153
-
err = tx.Commit()
154
-
if err != nil {
155
-
return fmt.Errorf("failed to commit changes: %w", err)
156
-
}
157
-
158
-
err = e.E.SavePolicy()
159
-
if err != nil {
160
-
return fmt.Errorf("failed to update ACLs: %w", err)
161
-
}
162
-
163
-
return nil
164
-
}
+9
-44
appview/settings/settings.go
+9
-44
appview/settings/settings.go
···
33
33
Config *config.Config
34
34
}
35
35
36
-
type tab = map[string]any
37
-
38
-
var (
39
-
settingsTabs []tab = []tab{
40
-
{"Name": "profile", "Icon": "user"},
41
-
{"Name": "keys", "Icon": "key"},
42
-
{"Name": "emails", "Icon": "mail"},
43
-
}
44
-
)
45
-
46
36
func (s *Settings) Router() http.Handler {
47
37
r := chi.NewRouter()
48
38
49
39
r.Use(middleware.AuthMiddleware(s.OAuth))
50
40
51
-
// settings pages
52
-
r.Get("/", s.profileSettings)
53
-
r.Get("/profile", s.profileSettings)
41
+
r.Get("/", s.settings)
54
42
55
43
r.Route("/keys", func(r chi.Router) {
56
-
r.Get("/", s.keysSettings)
57
44
r.Put("/", s.keys)
58
45
r.Delete("/", s.keys)
59
46
})
60
47
61
48
r.Route("/emails", func(r chi.Router) {
62
-
r.Get("/", s.emailsSettings)
63
49
r.Put("/", s.emails)
64
50
r.Delete("/", s.emails)
65
51
r.Get("/verify", s.emailsVerify)
···
70
56
return r
71
57
}
72
58
73
-
func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) {
74
-
user := s.OAuth.GetUser(r)
75
-
76
-
s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{
77
-
LoggedInUser: user,
78
-
Tabs: settingsTabs,
79
-
Tab: "profile",
80
-
})
81
-
}
82
-
83
-
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
59
+
func (s *Settings) settings(w http.ResponseWriter, r *http.Request) {
84
60
user := s.OAuth.GetUser(r)
85
61
pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did)
86
62
if err != nil {
87
63
log.Println(err)
88
64
}
89
65
90
-
s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{
91
-
LoggedInUser: user,
92
-
PubKeys: pubKeys,
93
-
Tabs: settingsTabs,
94
-
Tab: "keys",
95
-
})
96
-
}
97
-
98
-
func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) {
99
-
user := s.OAuth.GetUser(r)
100
66
emails, err := db.GetAllEmails(s.Db, user.Did)
101
67
if err != nil {
102
68
log.Println(err)
103
69
}
104
70
105
-
s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{
71
+
s.Pages.Settings(w, pages.SettingsParams{
106
72
LoggedInUser: user,
73
+
PubKeys: pubKeys,
107
74
Emails: emails,
108
-
Tabs: settingsTabs,
109
-
Tab: "emails",
110
75
})
111
76
}
112
77
···
236
201
return
237
202
}
238
203
239
-
s.Pages.HxLocation(w, "/settings/emails")
204
+
s.Pages.HxLocation(w, "/settings")
240
205
return
241
206
}
242
207
}
···
279
244
return
280
245
}
281
246
282
-
http.Redirect(w, r, "/settings/emails", http.StatusSeeOther)
247
+
http.Redirect(w, r, "/settings", http.StatusSeeOther)
283
248
}
284
249
285
250
func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) {
···
374
339
return
375
340
}
376
341
377
-
s.Pages.HxLocation(w, "/settings/emails")
342
+
s.Pages.HxLocation(w, "/settings")
378
343
}
379
344
380
345
func (s *Settings) keys(w http.ResponseWriter, r *http.Request) {
···
445
410
return
446
411
}
447
412
448
-
s.Pages.HxLocation(w, "/settings/keys")
413
+
s.Pages.HxLocation(w, "/settings")
449
414
return
450
415
451
416
case http.MethodDelete:
···
490
455
}
491
456
log.Println("deleted successfully")
492
457
493
-
s.Pages.HxLocation(w, "/settings/keys")
458
+
s.Pages.HxLocation(w, "/settings")
494
459
return
495
460
}
496
461
}
+8
-8
appview/spindles/spindles.go
+8
-8
appview/spindles/spindles.go
···
15
15
"tangled.sh/tangled.sh/core/appview/middleware"
16
16
"tangled.sh/tangled.sh/core/appview/oauth"
17
17
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/serververify"
18
+
verify "tangled.sh/tangled.sh/core/appview/spindleverify"
19
19
"tangled.sh/tangled.sh/core/idresolver"
20
20
"tangled.sh/tangled.sh/core/rbac"
21
21
"tangled.sh/tangled.sh/core/tid"
···
227
227
}
228
228
229
229
// begin verification
230
-
err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
230
+
err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
231
231
if err != nil {
232
232
l.Error("verification failed", "err", err)
233
233
s.Pages.HxRefresh(w)
234
234
return
235
235
}
236
236
237
-
_, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did)
237
+
_, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
238
238
if err != nil {
239
239
l.Error("failed to mark verified", "err", err)
240
240
s.Pages.HxRefresh(w)
···
400
400
}
401
401
402
402
// begin verification
403
-
err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
403
+
err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
404
404
if err != nil {
405
405
l.Error("verification failed", "err", err)
406
406
407
-
if errors.Is(err, serververify.FetchError) {
408
-
s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.")
407
+
if errors.Is(err, verify.FetchError) {
408
+
s.Pages.Notice(w, noticeId, err.Error())
409
409
return
410
410
}
411
411
412
-
if e, ok := err.(*serververify.OwnerMismatch); ok {
412
+
if e, ok := err.(*verify.OwnerMismatch); ok {
413
413
s.Pages.Notice(w, noticeId, e.Error())
414
414
return
415
415
}
···
418
418
return
419
419
}
420
420
421
-
rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did)
421
+
rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
422
422
if err != nil {
423
423
l.Error("failed to mark verified", "err", err)
424
424
s.Pages.Notice(w, noticeId, err.Error())
+118
appview/spindleverify/verify.go
+118
appview/spindleverify/verify.go
···
1
+
package spindleverify
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"fmt"
7
+
"io"
8
+
"net/http"
9
+
"strings"
10
+
"time"
11
+
12
+
"tangled.sh/tangled.sh/core/appview/db"
13
+
"tangled.sh/tangled.sh/core/rbac"
14
+
)
15
+
16
+
var (
17
+
FetchError = errors.New("failed to fetch owner")
18
+
)
19
+
20
+
// TODO: move this to "spindleclient" or similar
21
+
func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
22
+
scheme := "https"
23
+
if dev {
24
+
scheme = "http"
25
+
}
26
+
27
+
url := fmt.Sprintf("%s://%s/owner", scheme, domain)
28
+
req, err := http.NewRequest("GET", url, nil)
29
+
if err != nil {
30
+
return "", err
31
+
}
32
+
33
+
client := &http.Client{
34
+
Timeout: 1 * time.Second,
35
+
}
36
+
37
+
resp, err := client.Do(req.WithContext(ctx))
38
+
if err != nil || resp.StatusCode != 200 {
39
+
return "", fmt.Errorf("failed to fetch /owner")
40
+
}
41
+
42
+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
43
+
if err != nil {
44
+
return "", fmt.Errorf("failed to read /owner response: %w", err)
45
+
}
46
+
47
+
did := strings.TrimSpace(string(body))
48
+
if did == "" {
49
+
return "", fmt.Errorf("empty DID in /owner response")
50
+
}
51
+
52
+
return did, nil
53
+
}
54
+
55
+
type OwnerMismatch struct {
56
+
expected string
57
+
observed string
58
+
}
59
+
60
+
func (e *OwnerMismatch) Error() string {
61
+
return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed)
62
+
}
63
+
64
+
func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error {
65
+
// begin verification
66
+
observedOwner, err := fetchOwner(ctx, instance, dev)
67
+
if err != nil {
68
+
return fmt.Errorf("%w: %w", FetchError, err)
69
+
}
70
+
71
+
if observedOwner != expectedOwner {
72
+
return &OwnerMismatch{
73
+
expected: expectedOwner,
74
+
observed: observedOwner,
75
+
}
76
+
}
77
+
78
+
return nil
79
+
}
80
+
81
+
// mark this spindle as verified in the DB and add this user as its owner
82
+
func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) {
83
+
tx, err := d.Begin()
84
+
if err != nil {
85
+
return 0, fmt.Errorf("failed to create txn: %w", err)
86
+
}
87
+
defer func() {
88
+
tx.Rollback()
89
+
e.E.LoadPolicy()
90
+
}()
91
+
92
+
// mark this spindle as verified in the db
93
+
rowId, err := db.VerifySpindle(
94
+
tx,
95
+
db.FilterEq("owner", owner),
96
+
db.FilterEq("instance", instance),
97
+
)
98
+
if err != nil {
99
+
return 0, fmt.Errorf("failed to write to DB: %w", err)
100
+
}
101
+
102
+
err = e.AddSpindleOwner(instance, owner)
103
+
if err != nil {
104
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
105
+
}
106
+
107
+
err = tx.Commit()
108
+
if err != nil {
109
+
return 0, fmt.Errorf("failed to commit txn: %w", err)
110
+
}
111
+
112
+
err = e.E.SavePolicy()
113
+
if err != nil {
114
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
115
+
}
116
+
117
+
return rowId, nil
118
+
}
+2
-5
appview/state/knotstream.go
+2
-5
appview/state/knotstream.go
···
24
24
)
25
25
26
26
func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) {
27
-
knots, err := db.GetRegistrations(
28
-
d,
29
-
db.FilterIsNot("registered", "null"),
30
-
)
27
+
knots, err := db.GetCompletedRegistrations(d)
31
28
if err != nil {
32
29
return nil, err
33
30
}
34
31
35
32
srcs := make(map[ec.Source]struct{})
36
33
for _, k := range knots {
37
-
s := ec.NewKnotSource(k.Domain)
34
+
s := ec.NewKnotSource(k)
38
35
srcs[s] = struct{}{}
39
36
}
40
37
+62
-212
appview/state/profile.go
+62
-212
appview/state/profile.go
···
17
17
"github.com/gorilla/feeds"
18
18
"tangled.sh/tangled.sh/core/api/tangled"
19
19
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/oauth"
21
20
"tangled.sh/tangled.sh/core/appview/pages"
22
21
)
23
22
···
25
24
tabVal := r.URL.Query().Get("tab")
26
25
switch tabVal {
27
26
case "":
28
-
s.profileHomePage(w, r)
27
+
s.profilePage(w, r)
29
28
case "repos":
30
29
s.reposPage(w, r)
31
-
case "followers":
32
-
s.followersPage(w, r)
33
-
case "following":
34
-
s.followingPage(w, r)
35
30
}
36
31
}
37
32
38
-
type ProfilePageParams struct {
39
-
Id identity.Identity
40
-
LoggedInUser *oauth.User
41
-
Card pages.ProfileCard
42
-
}
43
-
44
-
func (s *State) profilePage(w http.ResponseWriter, r *http.Request) *ProfilePageParams {
33
+
func (s *State) profilePage(w http.ResponseWriter, r *http.Request) {
45
34
didOrHandle := chi.URLParam(r, "user")
46
35
if didOrHandle == "" {
47
-
http.Error(w, "bad request", http.StatusBadRequest)
48
-
return nil
36
+
http.Error(w, "Bad request", http.StatusBadRequest)
37
+
return
49
38
}
50
39
51
40
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
52
41
if !ok {
53
-
log.Printf("malformed middleware")
54
-
w.WriteHeader(http.StatusInternalServerError)
55
-
return nil
42
+
s.pages.Error404(w)
43
+
return
56
44
}
57
-
did := ident.DID.String()
58
45
59
-
profile, err := db.GetProfile(s.db, did)
46
+
profile, err := db.GetProfile(s.db, ident.DID.String())
60
47
if err != nil {
61
-
log.Printf("getting profile data for %s: %s", did, err)
62
-
s.pages.Error500(w)
63
-
return nil
48
+
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
64
49
}
65
50
66
-
followStats, err := db.GetFollowerFollowingCount(s.db, did)
67
-
if err != nil {
68
-
log.Printf("getting follow stats for %s: %s", did, err)
69
-
}
70
-
71
-
loggedInUser := s.oauth.GetUser(r)
72
-
followStatus := db.IsNotFollowing
73
-
if loggedInUser != nil {
74
-
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
75
-
}
76
-
77
-
return &ProfilePageParams{
78
-
Id: ident,
79
-
LoggedInUser: loggedInUser,
80
-
Card: pages.ProfileCard{
81
-
UserDid: did,
82
-
UserHandle: ident.Handle.String(),
83
-
Profile: profile,
84
-
FollowStatus: followStatus,
85
-
FollowersCount: followStats.Followers,
86
-
FollowingCount: followStats.Following,
87
-
},
88
-
}
89
-
}
90
-
91
-
func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) {
92
-
pageWithProfile := s.profilePage(w, r)
93
-
if pageWithProfile == nil {
94
-
return
95
-
}
96
-
97
-
id := pageWithProfile.Id
98
51
repos, err := db.GetRepos(
99
52
s.db,
100
53
0,
101
-
db.FilterEq("did", id.DID),
54
+
db.FilterEq("did", ident.DID.String()),
102
55
)
103
56
if err != nil {
104
-
log.Printf("getting repos for %s: %s", id.DID, err)
57
+
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
105
58
}
106
59
107
-
profile := pageWithProfile.Card.Profile
108
60
// filter out ones that are pinned
109
61
pinnedRepos := []db.Repo{}
110
62
for i, r := range repos {
···
119
71
}
120
72
}
121
73
122
-
collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String())
74
+
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
123
75
if err != nil {
124
-
log.Printf("getting collaborating repos for %s: %s", id.DID, err)
76
+
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
125
77
}
126
78
127
79
pinnedCollaboratingRepos := []db.Repo{}
···
132
84
}
133
85
}
134
86
135
-
timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
87
+
timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String())
136
88
if err != nil {
137
-
log.Printf("failed to create profile timeline for %s: %s", id.DID, err)
89
+
log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
138
90
}
139
91
140
-
var didsToResolve []string
141
-
for _, r := range collaboratingRepos {
142
-
didsToResolve = append(didsToResolve, r.Did)
92
+
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
93
+
if err != nil {
94
+
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
143
95
}
144
-
for _, byMonth := range timeline.ByMonth {
145
-
for _, pe := range byMonth.PullEvents.Items {
146
-
didsToResolve = append(didsToResolve, pe.Repo.Did)
147
-
}
148
-
for _, ie := range byMonth.IssueEvents.Items {
149
-
didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
150
-
}
151
-
for _, re := range byMonth.RepoEvents {
152
-
didsToResolve = append(didsToResolve, re.Repo.Did)
153
-
if re.Source != nil {
154
-
didsToResolve = append(didsToResolve, re.Source.Did)
155
-
}
156
-
}
96
+
97
+
loggedInUser := s.oauth.GetUser(r)
98
+
followStatus := db.IsNotFollowing
99
+
if loggedInUser != nil {
100
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
157
101
}
158
102
159
103
now := time.Now()
160
104
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
161
105
punchcard, err := db.MakePunchcard(
162
106
s.db,
163
-
db.FilterEq("did", id.DID),
107
+
db.FilterEq("did", ident.DID.String()),
164
108
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
165
109
db.FilterLte("date", now.Format(time.DateOnly)),
166
110
)
167
111
if err != nil {
168
-
log.Println("failed to get punchcard for did", "did", id.DID, "err", err)
112
+
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
169
113
}
170
114
171
-
s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{
172
-
LoggedInUser: pageWithProfile.LoggedInUser,
115
+
s.pages.ProfilePage(w, pages.ProfilePageParams{
116
+
LoggedInUser: loggedInUser,
173
117
Repos: pinnedRepos,
174
118
CollaboratingRepos: pinnedCollaboratingRepos,
175
-
Card: pageWithProfile.Card,
176
-
Punchcard: punchcard,
177
-
ProfileTimeline: timeline,
119
+
Card: pages.ProfileCard{
120
+
UserDid: ident.DID.String(),
121
+
UserHandle: ident.Handle.String(),
122
+
Profile: profile,
123
+
FollowStatus: followStatus,
124
+
Followers: followers,
125
+
Following: following,
126
+
},
127
+
Punchcard: punchcard,
128
+
ProfileTimeline: timeline,
178
129
})
179
130
}
180
131
181
132
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
182
-
pageWithProfile := s.profilePage(w, r)
183
-
if pageWithProfile == nil {
133
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
134
+
if !ok {
135
+
s.pages.Error404(w)
184
136
return
185
137
}
186
138
187
-
id := pageWithProfile.Id
139
+
profile, err := db.GetProfile(s.db, ident.DID.String())
140
+
if err != nil {
141
+
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
142
+
}
143
+
188
144
repos, err := db.GetRepos(
189
145
s.db,
190
146
0,
191
-
db.FilterEq("did", id.DID),
147
+
db.FilterEq("did", ident.DID.String()),
192
148
)
193
149
if err != nil {
194
-
log.Printf("getting repos for %s: %s", id.DID, err)
195
-
}
196
-
197
-
s.pages.ReposPage(w, pages.ReposPageParams{
198
-
LoggedInUser: pageWithProfile.LoggedInUser,
199
-
Repos: repos,
200
-
Card: pageWithProfile.Card,
201
-
})
202
-
}
203
-
204
-
type FollowsPageParams struct {
205
-
LoggedInUser *oauth.User
206
-
Follows []pages.FollowCard
207
-
Card pages.ProfileCard
208
-
}
209
-
210
-
func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) {
211
-
pageWithProfile := s.profilePage(w, r)
212
-
if pageWithProfile == nil {
213
-
return FollowsPageParams{}, nil
150
+
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
214
151
}
215
152
216
-
id := pageWithProfile.Id
217
-
loggedInUser := pageWithProfile.LoggedInUser
218
-
219
-
follows, err := fetchFollows(s.db, id.DID.String())
220
-
if err != nil {
221
-
log.Printf("getting followers for %s: %s", id.DID, err)
222
-
return FollowsPageParams{}, err
153
+
loggedInUser := s.oauth.GetUser(r)
154
+
followStatus := db.IsNotFollowing
155
+
if loggedInUser != nil {
156
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
223
157
}
224
158
225
-
if len(follows) == 0 {
226
-
return FollowsPageParams{
227
-
LoggedInUser: loggedInUser,
228
-
Follows: []pages.FollowCard{},
229
-
Card: pageWithProfile.Card,
230
-
}, nil
231
-
}
232
-
233
-
followDids := make([]string, 0, len(follows))
234
-
for _, follow := range follows {
235
-
followDids = append(followDids, extractDid(follow))
236
-
}
237
-
238
-
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
159
+
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
239
160
if err != nil {
240
-
log.Printf("getting profile for %s: %s", followDids, err)
241
-
return FollowsPageParams{}, err
242
-
}
243
-
244
-
followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
245
-
if err != nil {
246
-
log.Printf("getting follow counts for %s: %s", followDids, err)
247
-
}
248
-
249
-
var loggedInUserFollowing map[string]struct{}
250
-
if loggedInUser != nil {
251
-
following, err := db.GetFollowing(s.db, loggedInUser.Did)
252
-
if err != nil {
253
-
return FollowsPageParams{}, err
254
-
}
255
-
if len(following) > 0 {
256
-
loggedInUserFollowing = make(map[string]struct{}, len(following))
257
-
for _, follow := range following {
258
-
loggedInUserFollowing[follow.SubjectDid] = struct{}{}
259
-
}
260
-
}
161
+
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
261
162
}
262
163
263
-
followCards := make([]pages.FollowCard, 0, len(follows))
264
-
for _, did := range followDids {
265
-
followStats, exists := followStatsMap[did]
266
-
if !exists {
267
-
followStats = db.FollowStats{}
268
-
}
269
-
followStatus := db.IsNotFollowing
270
-
if loggedInUserFollowing != nil {
271
-
if _, exists := loggedInUserFollowing[did]; exists {
272
-
followStatus = db.IsFollowing
273
-
} else if loggedInUser.Did == did {
274
-
followStatus = db.IsSelf
275
-
}
276
-
}
277
-
var profile *db.Profile
278
-
if p, exists := profiles[did]; exists {
279
-
profile = p
280
-
} else {
281
-
profile = &db.Profile{}
282
-
profile.Did = did
283
-
}
284
-
followCards = append(followCards, pages.FollowCard{
285
-
UserDid: did,
286
-
FollowStatus: followStatus,
287
-
FollowersCount: followStats.Followers,
288
-
FollowingCount: followStats.Following,
289
-
Profile: profile,
290
-
})
291
-
}
292
-
293
-
return FollowsPageParams{
164
+
s.pages.ReposPage(w, pages.ReposPageParams{
294
165
LoggedInUser: loggedInUser,
295
-
Follows: followCards,
296
-
Card: pageWithProfile.Card,
297
-
}, nil
298
-
}
299
-
300
-
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
301
-
followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
302
-
if err != nil {
303
-
s.pages.Notice(w, "all-followers", "Failed to load followers")
304
-
return
305
-
}
306
-
307
-
s.pages.FollowersPage(w, pages.FollowersPageParams{
308
-
LoggedInUser: followPage.LoggedInUser,
309
-
Followers: followPage.Follows,
310
-
Card: followPage.Card,
311
-
})
312
-
}
313
-
314
-
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
315
-
followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
316
-
if err != nil {
317
-
s.pages.Notice(w, "all-following", "Failed to load following")
318
-
return
319
-
}
320
-
321
-
s.pages.FollowingPage(w, pages.FollowingPageParams{
322
-
LoggedInUser: followPage.LoggedInUser,
323
-
Following: followPage.Follows,
324
-
Card: followPage.Card,
166
+
Repos: repos,
167
+
Card: pages.ProfileCard{
168
+
UserDid: ident.DID.String(),
169
+
UserHandle: ident.Handle.String(),
170
+
Profile: profile,
171
+
FollowStatus: followStatus,
172
+
Followers: followers,
173
+
Following: following,
174
+
},
325
175
})
326
176
}
327
177
+3
-3
appview/state/router.go
+3
-3
appview/state/router.go
···
147
147
148
148
r.Mount("/settings", s.SettingsRouter())
149
149
r.Mount("/strings", s.StringsRouter(mw))
150
-
r.Mount("/knots", s.KnotsRouter())
150
+
r.Mount("/knots", s.KnotsRouter(mw))
151
151
r.Mount("/spindles", s.SpindlesRouter())
152
152
r.Mount("/signup", s.SignupRouter())
153
153
r.Mount("/", s.OAuthRouter())
···
195
195
return spindles.Router()
196
196
}
197
197
198
-
func (s *State) KnotsRouter() http.Handler {
198
+
func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler {
199
199
logger := log.New("knots")
200
200
201
201
knots := &knots.Knots{
···
209
209
Logger: logger,
210
210
}
211
211
212
-
return knots.Router()
212
+
return knots.Router(mw)
213
213
}
214
214
215
215
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
+41
-95
appview/state/state.go
+41
-95
appview/state/state.go
···
2
2
3
3
import (
4
4
"context"
5
-
"database/sql"
6
-
"errors"
7
5
"fmt"
8
6
"log"
9
7
"log/slog"
···
12
10
"time"
13
11
14
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
-
"github.com/bluesky-social/indigo/atproto/syntax"
16
13
lexutil "github.com/bluesky-social/indigo/lex/util"
17
14
securejoin "github.com/cyphar/filepath-securejoin"
18
15
"github.com/go-chi/chi/v5"
···
28
25
"tangled.sh/tangled.sh/core/appview/pages"
29
26
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
30
27
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
-
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
32
28
"tangled.sh/tangled.sh/core/eventconsumer"
33
29
"tangled.sh/tangled.sh/core/idresolver"
34
30
"tangled.sh/tangled.sh/core/jetstream"
31
+
"tangled.sh/tangled.sh/core/knotclient"
35
32
tlog "tangled.sh/tangled.sh/core/log"
36
33
"tangled.sh/tangled.sh/core/rbac"
37
34
"tangled.sh/tangled.sh/core/tid"
38
-
// xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
39
35
)
40
36
41
37
type State struct {
···
52
48
repoResolver *reporesolver.RepoResolver
53
49
knotstream *eventconsumer.Consumer
54
50
spindlestream *eventconsumer.Consumer
55
-
logger *slog.Logger
56
51
}
57
52
58
53
func Make(ctx context.Context, config *config.Config) (*State, error) {
···
99
94
tangled.SpindleMemberNSID,
100
95
tangled.SpindleNSID,
101
96
tangled.StringNSID,
97
+
tangled.RepoIssueNSID,
98
+
tangled.RepoIssueCommentNSID,
102
99
},
103
100
nil,
104
101
slog.Default(),
···
157
154
repoResolver,
158
155
knotstream,
159
156
spindlestream,
160
-
slog.Default(),
161
157
}
162
158
163
159
return state, nil
···
297
293
})
298
294
299
295
case http.MethodPost:
300
-
l := s.logger.With("handler", "NewRepo")
301
-
302
296
user := s.oauth.GetUser(r)
303
-
l = l.With("did", user.Did)
304
-
l = l.With("handle", user.Handle)
305
297
306
-
// form validation
307
298
domain := r.FormValue("domain")
308
299
if domain == "" {
309
300
s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
310
301
return
311
302
}
312
-
l = l.With("knot", domain)
313
303
314
304
repoName := r.FormValue("name")
315
305
if repoName == "" {
···
321
311
s.pages.Notice(w, "repo", err.Error())
322
312
return
323
313
}
314
+
324
315
repoName = stripGitExt(repoName)
325
-
l = l.With("repoName", repoName)
326
316
327
317
defaultBranch := r.FormValue("branch")
328
318
if defaultBranch == "" {
329
319
defaultBranch = "main"
330
320
}
331
-
l = l.With("defaultBranch", defaultBranch)
332
321
333
322
description := r.FormValue("description")
334
323
335
-
// ACL validation
336
324
ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
337
325
if err != nil || !ok {
338
-
l.Info("unauthorized")
339
326
s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
340
327
return
341
328
}
342
329
343
-
// Check for existing repos
344
330
existingRepo, err := db.GetRepo(s.db, user.Did, repoName)
345
331
if err == nil && existingRepo != nil {
346
-
l.Info("repo exists")
347
-
s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot))
332
+
s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot))
333
+
return
334
+
}
335
+
336
+
secret, err := db.GetRegistrationKey(s.db, domain)
337
+
if err != nil {
338
+
s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
348
339
return
349
340
}
350
341
351
-
// create atproto record for this repo
342
+
client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
343
+
if err != nil {
344
+
s.pages.Notice(w, "repo", "Failed to connect to knot server.")
345
+
return
346
+
}
347
+
352
348
rkey := tid.TID()
353
349
repo := &db.Repo{
354
350
Did: user.Did,
···
360
356
361
357
xrpcClient, err := s.oauth.AuthorizedClient(r)
362
358
if err != nil {
363
-
l.Info("PDS write failed", "err", err)
364
359
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
365
360
return
366
361
}
···
379
374
}},
380
375
})
381
376
if err != nil {
382
-
l.Info("PDS write failed", "err", err)
377
+
log.Printf("failed to create record: %s", err)
383
378
s.pages.Notice(w, "repo", "Failed to announce repository creation.")
384
379
return
385
380
}
386
-
387
-
aturi := atresp.Uri
388
-
l = l.With("aturi", aturi)
389
-
l.Info("wrote to PDS")
381
+
log.Println("created repo record: ", atresp.Uri)
390
382
391
383
tx, err := s.db.BeginTx(r.Context(), nil)
392
384
if err != nil {
393
-
l.Info("txn failed", "err", err)
385
+
log.Println(err)
394
386
s.pages.Notice(w, "repo", "Failed to save repository information.")
395
387
return
396
388
}
397
-
398
-
// The rollback function reverts a few things on failure:
399
-
// - the pending txn
400
-
// - the ACLs
401
-
// - the atproto record created
402
-
rollback := func() {
403
-
err1 := tx.Rollback()
404
-
err2 := s.enforcer.E.LoadPolicy()
405
-
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
406
-
407
-
// ignore txn complete errors, this is okay
408
-
if errors.Is(err1, sql.ErrTxDone) {
409
-
err1 = nil
410
-
}
411
-
412
-
if errs := errors.Join(err1, err2, err3); errs != nil {
413
-
l.Error("failed to rollback changes", "errs", errs)
414
-
return
389
+
defer func() {
390
+
tx.Rollback()
391
+
err = s.enforcer.E.LoadPolicy()
392
+
if err != nil {
393
+
log.Println("failed to rollback policies")
415
394
}
416
-
}
417
-
defer rollback()
395
+
}()
418
396
419
-
client, err := s.oauth.ServiceClient(
420
-
r,
421
-
oauth.WithService(domain),
422
-
oauth.WithLxm(tangled.RepoCreateNSID),
423
-
oauth.WithDev(s.config.Core.Dev),
424
-
)
397
+
resp, err := client.NewRepo(user.Did, repoName, defaultBranch)
425
398
if err != nil {
426
-
l.Error("service auth failed", "err", err)
427
-
s.pages.Notice(w, "repo", "Failed to reach PDS.")
399
+
s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
428
400
return
429
401
}
430
402
431
-
xe := tangled.RepoCreate(
432
-
r.Context(),
433
-
client,
434
-
&tangled.RepoCreate_Input{
435
-
Rkey: rkey,
436
-
},
437
-
)
438
-
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
439
-
l.Error("xrpc error", "xe", xe)
440
-
s.pages.Notice(w, "repo", err.Error())
403
+
switch resp.StatusCode {
404
+
case http.StatusConflict:
405
+
s.pages.Notice(w, "repo", "A repository with that name already exists.")
441
406
return
407
+
case http.StatusInternalServerError:
408
+
s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
409
+
case http.StatusNoContent:
410
+
// continue
442
411
}
443
412
444
413
err = db.AddRepo(tx, repo)
445
414
if err != nil {
446
-
l.Error("db write failed", "err", err)
415
+
log.Println(err)
447
416
s.pages.Notice(w, "repo", "Failed to save repository information.")
448
417
return
449
418
}
···
452
421
p, _ := securejoin.SecureJoin(user.Did, repoName)
453
422
err = s.enforcer.AddRepo(user.Did, domain, p)
454
423
if err != nil {
455
-
l.Error("acl setup failed", "err", err)
424
+
log.Println(err)
456
425
s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
457
426
return
458
427
}
459
428
460
429
err = tx.Commit()
461
430
if err != nil {
462
-
l.Error("txn commit failed", "err", err)
431
+
log.Println("failed to commit changes", err)
463
432
http.Error(w, err.Error(), http.StatusInternalServerError)
464
433
return
465
434
}
466
435
467
436
err = s.enforcer.E.SavePolicy()
468
437
if err != nil {
469
-
l.Error("acl save failed", "err", err)
438
+
log.Println("failed to update ACLs", err)
470
439
http.Error(w, err.Error(), http.StatusInternalServerError)
471
440
return
472
441
}
473
442
474
-
// reset the ATURI because the transaction completed successfully
475
-
aturi = ""
476
-
477
443
s.notifier.NewRepo(r.Context(), repo)
478
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
479
-
}
480
-
}
481
444
482
-
// this is used to rollback changes made to the PDS
483
-
//
484
-
// it is a no-op if the provided ATURI is empty
485
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
486
-
if aturi == "" {
487
-
return nil
445
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
446
+
return
488
447
}
489
-
490
-
parsed := syntax.ATURI(aturi)
491
-
492
-
collection := parsed.Collection().String()
493
-
repo := parsed.Authority().String()
494
-
rkey := parsed.RecordKey().String()
495
-
496
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
497
-
Collection: collection,
498
-
Repo: repo,
499
-
Rkey: rkey,
500
-
})
501
-
return err
502
448
}
+7
-7
appview/strings/strings.go
+7
-7
appview/strings/strings.go
···
202
202
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
203
203
}
204
204
205
-
followStats, err := db.GetFollowerFollowingCount(s.Db, id.DID.String())
205
+
followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String())
206
206
if err != nil {
207
207
l.Error("failed to get follow stats", "err", err)
208
208
}
···
210
210
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
211
211
LoggedInUser: s.OAuth.GetUser(r),
212
212
Card: pages.ProfileCard{
213
-
UserDid: id.DID.String(),
214
-
UserHandle: id.Handle.String(),
215
-
Profile: profile,
216
-
FollowStatus: followStatus,
217
-
FollowersCount: followStats.Followers,
218
-
FollowingCount: followStats.Following,
213
+
UserDid: id.DID.String(),
214
+
UserHandle: id.Handle.String(),
215
+
Profile: profile,
216
+
FollowStatus: followStatus,
217
+
Followers: followers,
218
+
Following: following,
219
219
},
220
220
Strings: all,
221
221
})
-25
appview/xrpcclient/xrpc.go
-25
appview/xrpcclient/xrpc.go
···
3
3
import (
4
4
"bytes"
5
5
"context"
6
-
"errors"
7
-
"fmt"
8
6
"io"
9
-
"net/http"
10
7
11
8
"github.com/bluesky-social/indigo/api/atproto"
12
9
"github.com/bluesky-social/indigo/xrpc"
13
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
14
10
oauth "tangled.sh/icyphox.sh/atproto-oauth"
15
11
)
16
12
···
106
102
107
103
return &out, nil
108
104
}
109
-
110
-
// produces a more manageable error
111
-
func HandleXrpcErr(err error) error {
112
-
if err == nil {
113
-
return nil
114
-
}
115
-
116
-
var xrpcerr *indigoxrpc.Error
117
-
if ok := errors.As(err, &xrpcerr); !ok {
118
-
return fmt.Errorf("Recieved invalid XRPC error response.")
119
-
}
120
-
121
-
switch xrpcerr.StatusCode {
122
-
case http.StatusNotFound:
123
-
return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.")
124
-
case http.StatusUnauthorized:
125
-
return fmt.Errorf("Unauthorized XRPC request.")
126
-
default:
127
-
return fmt.Errorf("Failed to perform operation. Try again later.")
128
-
}
129
-
}
-1
cmd/gen.go
-1
cmd/gen.go
+19
-19
docs/hacking.md
+19
-19
docs/hacking.md
···
55
55
quite cumbersome. So the nix flake provides a
56
56
`nixosConfiguration` to do so.
57
57
58
-
To begin, grab your DID from http://localhost:3000/settings.
59
-
Then, set `TANGLED_VM_KNOT_OWNER` and
60
-
`TANGLED_VM_SPINDLE_OWNER` to your DID.
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.
61
63
62
-
If you don't want to [set up a spindle](#running-a-spindle),
63
-
you can use any placeholder value.
64
+
You will also need to set the `$TANGLED_VM_SPINDLE_OWNER`
65
+
variable to some value. If you don't want to [set up a
66
+
spindle](#running-a-spindle), you can use any placeholder
67
+
value.
64
68
65
69
You can now start a lightweight NixOS VM like so:
66
70
···
71
75
```
72
76
73
77
This starts a knot on port 6000, a spindle on port 6555
74
-
with `ssh` exposed on port 2222.
75
-
76
-
Once the services are running, head to
77
-
http://localhost:3000/knots and hit verify (and similarly,
78
-
http://localhost:3000/spindles to verify your spindle). It
79
-
should verify the ownership of the services instantly if
80
-
everything went smoothly.
81
-
82
-
You can push repositories to this VM with this ssh config
83
-
block on your main machine:
78
+
with `ssh` exposed on port 2222. You can push repositories
79
+
to this VM with this ssh config block on your main machine:
84
80
85
81
```bash
86
82
Host nixos-shell
···
99
95
100
96
## running a spindle
101
97
102
-
The above VM should already be running a spindle on
103
-
`localhost:6555`. Head to http://localhost:3000/spindles and
104
-
hit verify. You can then configure each repository to use
105
-
this spindle and run CI jobs.
98
+
You will need to find out your DID by entering your login handle into
99
+
<https://pdsls.dev/>. Set `$TANGLED_VM_SPINDLE_OWNER` to your DID.
100
+
101
+
The above VM should already be running a spindle on `localhost:6555`.
102
+
You can head to the spindle dashboard on `http://localhost:3000/spindles`,
103
+
and register a spindle with hostname `localhost:6555`. It should instantly
104
+
be verified. You can then configure each repository to use this spindle
105
+
and run CI jobs.
106
106
107
107
Of interest when debugging spindles:
108
108
+5
-7
docs/knot-hosting.md
+5
-7
docs/knot-hosting.md
···
73
73
```
74
74
75
75
Create `/home/git/.knot.env` with the following, updating the values as
76
-
necessary. The `KNOT_SERVER_OWNER` should be set to your
77
-
DID, you can find your DID in the [Settings](https://tangled.sh/settings) page.
76
+
necessary. The `KNOT_SERVER_SECRET` can be obtained from the
77
+
[/knots](https://tangled.sh/knots) page on Tangled.
78
78
79
79
```
80
80
KNOT_REPO_SCAN_PATH=/home/git
81
81
KNOT_SERVER_HOSTNAME=knot.example.com
82
82
APPVIEW_ENDPOINT=https://tangled.sh
83
-
KNOT_SERVER_OWNER=did:plc:foobar
83
+
KNOT_SERVER_SECRET=secret
84
84
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
85
85
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
86
86
```
···
128
128
Remember to use Let's Encrypt or similar to procure a certificate for your
129
129
knot domain.
130
130
131
-
You should now have a running knot server! You can finalize
132
-
your registration by hitting the `verify` button on the
133
-
[/knots](https://tangled.sh/knots) page. This simply creates
134
-
a record on your PDS to announce the existence of the knot.
131
+
You should now have a running knot server! You can finalize your registration by hitting the
132
+
`initialize` button on the [/knots](https://tangled.sh/knots) page.
135
133
136
134
### custom paths
137
135
-35
docs/migrations/knot-1.7.0.md
-35
docs/migrations/knot-1.7.0.md
···
1
-
# Upgrading from v1.7.0
2
-
3
-
After v1.7.0, knot secrets have been deprecated. You no
4
-
longer need a secret from the appview to run a knot. All
5
-
authorized commands to knots are managed via [Inter-Service
6
-
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
7
-
Knots will be read-only until upgraded.
8
-
9
-
Upgrading is quite easy, in essence:
10
-
11
-
- `KNOT_SERVER_SECRET` is no more, you can remove this
12
-
environment variable entirely
13
-
- `KNOT_SERVER_OWNER` is now required on boot, set this to
14
-
your DID. You can find your DID in the
15
-
[settings](https://tangled.sh/settings) page.
16
-
- Restart your knot once you have replaced the environment
17
-
variable
18
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
19
-
hit the "retry" button to verify your knot. This simply
20
-
writes a `sh.tangled.knot` record to your PDS.
21
-
22
-
## Nix
23
-
24
-
If you use the nix module, simply bump the flake to the
25
-
latest revision, and change your config block like so:
26
-
27
-
```diff
28
-
services.tangled-knot = {
29
-
enable = true;
30
-
server = {
31
-
- secretFile = /path/to/secret;
32
-
+ owner = "did:plc:foo";
33
-
};
34
-
};
35
-
```
+1
-1
flake.nix
+1
-1
flake.nix
···
252
252
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
253
253
cd "$rootDir"
254
254
255
-
rm -f api/tangled/*
255
+
rm api/tangled/*
256
256
lexgen --build-file lexicon-build-config.json lexicons
257
257
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
258
258
${pkgs.gotools}/bin/goimports -w api/tangled/*
+336
knotclient/signer.go
+336
knotclient/signer.go
···
1
+
package knotclient
2
+
3
+
import (
4
+
"bytes"
5
+
"crypto/hmac"
6
+
"crypto/sha256"
7
+
"encoding/hex"
8
+
"encoding/json"
9
+
"fmt"
10
+
"io"
11
+
"log"
12
+
"net/http"
13
+
"net/url"
14
+
"time"
15
+
16
+
"tangled.sh/tangled.sh/core/types"
17
+
)
18
+
19
+
type SignerTransport struct {
20
+
Secret string
21
+
}
22
+
23
+
func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
24
+
timestamp := time.Now().Format(time.RFC3339)
25
+
mac := hmac.New(sha256.New, []byte(s.Secret))
26
+
message := req.Method + req.URL.Path + timestamp
27
+
mac.Write([]byte(message))
28
+
signature := hex.EncodeToString(mac.Sum(nil))
29
+
req.Header.Set("X-Signature", signature)
30
+
req.Header.Set("X-Timestamp", timestamp)
31
+
return http.DefaultTransport.RoundTrip(req)
32
+
}
33
+
34
+
type SignedClient struct {
35
+
Secret string
36
+
Url *url.URL
37
+
client *http.Client
38
+
}
39
+
40
+
func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) {
41
+
client := &http.Client{
42
+
Timeout: 5 * time.Second,
43
+
Transport: SignerTransport{
44
+
Secret: secret,
45
+
},
46
+
}
47
+
48
+
scheme := "https"
49
+
if dev {
50
+
scheme = "http"
51
+
}
52
+
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
53
+
if err != nil {
54
+
return nil, err
55
+
}
56
+
57
+
signedClient := &SignedClient{
58
+
Secret: secret,
59
+
client: client,
60
+
Url: url,
61
+
}
62
+
63
+
return signedClient, nil
64
+
}
65
+
66
+
func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) {
67
+
return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body))
68
+
}
69
+
70
+
func (s *SignedClient) Init(did string) (*http.Response, error) {
71
+
const (
72
+
Method = "POST"
73
+
Endpoint = "/init"
74
+
)
75
+
76
+
body, _ := json.Marshal(map[string]any{
77
+
"did": did,
78
+
})
79
+
80
+
req, err := s.newRequest(Method, Endpoint, body)
81
+
if err != nil {
82
+
return nil, err
83
+
}
84
+
85
+
return s.client.Do(req)
86
+
}
87
+
88
+
func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) {
89
+
const (
90
+
Method = "PUT"
91
+
Endpoint = "/repo/new"
92
+
)
93
+
94
+
body, _ := json.Marshal(map[string]any{
95
+
"did": did,
96
+
"name": repoName,
97
+
"default_branch": defaultBranch,
98
+
})
99
+
100
+
req, err := s.newRequest(Method, Endpoint, body)
101
+
if err != nil {
102
+
return nil, err
103
+
}
104
+
105
+
return s.client.Do(req)
106
+
}
107
+
108
+
func (s *SignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) {
109
+
const (
110
+
Method = "GET"
111
+
)
112
+
endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref))
113
+
114
+
req, err := s.newRequest(Method, endpoint, nil)
115
+
if err != nil {
116
+
return nil, err
117
+
}
118
+
119
+
resp, err := s.client.Do(req)
120
+
if err != nil {
121
+
return nil, err
122
+
}
123
+
124
+
var result types.RepoLanguageResponse
125
+
if resp.StatusCode != http.StatusOK {
126
+
log.Println("failed to calculate languages", resp.Status)
127
+
return &types.RepoLanguageResponse{}, nil
128
+
}
129
+
130
+
body, err := io.ReadAll(resp.Body)
131
+
if err != nil {
132
+
return nil, err
133
+
}
134
+
135
+
err = json.Unmarshal(body, &result)
136
+
if err != nil {
137
+
return nil, err
138
+
}
139
+
140
+
return &result, nil
141
+
}
142
+
143
+
func (s *SignedClient) RepoForkAheadBehind(ownerDid, source, name, branch, hiddenRef string) (*http.Response, error) {
144
+
const (
145
+
Method = "GET"
146
+
)
147
+
endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch))
148
+
149
+
body, _ := json.Marshal(map[string]any{
150
+
"did": ownerDid,
151
+
"source": source,
152
+
"name": name,
153
+
"hiddenref": hiddenRef,
154
+
})
155
+
156
+
req, err := s.newRequest(Method, endpoint, body)
157
+
if err != nil {
158
+
return nil, err
159
+
}
160
+
161
+
return s.client.Do(req)
162
+
}
163
+
164
+
func (s *SignedClient) SyncRepoFork(ownerDid, source, name, branch string) (*http.Response, error) {
165
+
const (
166
+
Method = "POST"
167
+
)
168
+
endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch))
169
+
170
+
body, _ := json.Marshal(map[string]any{
171
+
"did": ownerDid,
172
+
"source": source,
173
+
"name": name,
174
+
})
175
+
176
+
req, err := s.newRequest(Method, endpoint, body)
177
+
if err != nil {
178
+
return nil, err
179
+
}
180
+
181
+
return s.client.Do(req)
182
+
}
183
+
184
+
func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) {
185
+
const (
186
+
Method = "POST"
187
+
Endpoint = "/repo/fork"
188
+
)
189
+
190
+
body, _ := json.Marshal(map[string]any{
191
+
"did": ownerDid,
192
+
"source": source,
193
+
"name": name,
194
+
})
195
+
196
+
req, err := s.newRequest(Method, Endpoint, body)
197
+
if err != nil {
198
+
return nil, err
199
+
}
200
+
201
+
return s.client.Do(req)
202
+
}
203
+
204
+
func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) {
205
+
const (
206
+
Method = "DELETE"
207
+
Endpoint = "/repo"
208
+
)
209
+
210
+
body, _ := json.Marshal(map[string]any{
211
+
"did": did,
212
+
"name": repoName,
213
+
})
214
+
215
+
req, err := s.newRequest(Method, Endpoint, body)
216
+
if err != nil {
217
+
return nil, err
218
+
}
219
+
220
+
return s.client.Do(req)
221
+
}
222
+
223
+
func (s *SignedClient) AddMember(did string) (*http.Response, error) {
224
+
const (
225
+
Method = "PUT"
226
+
Endpoint = "/member/add"
227
+
)
228
+
229
+
body, _ := json.Marshal(map[string]any{
230
+
"did": did,
231
+
})
232
+
233
+
req, err := s.newRequest(Method, Endpoint, body)
234
+
if err != nil {
235
+
return nil, err
236
+
}
237
+
238
+
return s.client.Do(req)
239
+
}
240
+
241
+
func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) {
242
+
const (
243
+
Method = "PUT"
244
+
)
245
+
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
246
+
247
+
body, _ := json.Marshal(map[string]any{
248
+
"branch": branch,
249
+
})
250
+
251
+
req, err := s.newRequest(Method, endpoint, body)
252
+
if err != nil {
253
+
return nil, err
254
+
}
255
+
256
+
return s.client.Do(req)
257
+
}
258
+
259
+
func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) {
260
+
const (
261
+
Method = "POST"
262
+
)
263
+
endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName)
264
+
265
+
body, _ := json.Marshal(map[string]any{
266
+
"did": memberDid,
267
+
})
268
+
269
+
req, err := s.newRequest(Method, endpoint, body)
270
+
if err != nil {
271
+
return nil, err
272
+
}
273
+
274
+
return s.client.Do(req)
275
+
}
276
+
277
+
func (s *SignedClient) Merge(
278
+
patch []byte,
279
+
ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string,
280
+
) (*http.Response, error) {
281
+
const (
282
+
Method = "POST"
283
+
)
284
+
endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo)
285
+
286
+
mr := types.MergeRequest{
287
+
Branch: branch,
288
+
CommitMessage: commitMessage,
289
+
CommitBody: commitBody,
290
+
AuthorName: authorName,
291
+
AuthorEmail: authorEmail,
292
+
Patch: string(patch),
293
+
}
294
+
295
+
body, _ := json.Marshal(mr)
296
+
297
+
req, err := s.newRequest(Method, endpoint, body)
298
+
if err != nil {
299
+
return nil, err
300
+
}
301
+
302
+
return s.client.Do(req)
303
+
}
304
+
305
+
func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) {
306
+
const (
307
+
Method = "POST"
308
+
)
309
+
endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo)
310
+
311
+
body, _ := json.Marshal(map[string]any{
312
+
"patch": string(patch),
313
+
"branch": branch,
314
+
})
315
+
316
+
req, err := s.newRequest(Method, endpoint, body)
317
+
if err != nil {
318
+
return nil, err
319
+
}
320
+
321
+
return s.client.Do(req)
322
+
}
323
+
324
+
func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) {
325
+
const (
326
+
Method = "POST"
327
+
)
328
+
endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch))
329
+
330
+
req, err := s.newRequest(Method, endpoint, nil)
331
+
if err != nil {
332
+
return nil, err
333
+
}
334
+
335
+
return s.client.Do(req)
336
+
}
-35
knotclient/unsigned.go
-35
knotclient/unsigned.go
···
248
248
249
249
return &formatPatchResponse, nil
250
250
}
251
-
252
-
func (s *UnsignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) {
253
-
const (
254
-
Method = "GET"
255
-
)
256
-
endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref))
257
-
258
-
req, err := s.newRequest(Method, endpoint, nil, nil)
259
-
if err != nil {
260
-
return nil, err
261
-
}
262
-
263
-
resp, err := s.client.Do(req)
264
-
if err != nil {
265
-
return nil, err
266
-
}
267
-
268
-
var result types.RepoLanguageResponse
269
-
if resp.StatusCode != http.StatusOK {
270
-
log.Println("failed to calculate languages", resp.Status)
271
-
return &types.RepoLanguageResponse{}, nil
272
-
}
273
-
274
-
body, err := io.ReadAll(resp.Body)
275
-
if err != nil {
276
-
return nil, err
277
-
}
278
-
279
-
err = json.Unmarshal(body, &result)
280
-
if err != nil {
281
-
return nil, err
282
-
}
283
-
284
-
return &result, nil
285
-
}
+1
-1
knotserver/config/config.go
+1
-1
knotserver/config/config.go
···
17
17
type Server struct {
18
18
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:5555"`
19
19
InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"`
20
+
Secret string `env:"SECRET, required"`
20
21
DBPath string `env:"DB_PATH, default=knotserver.db"`
21
22
Hostname string `env:"HOSTNAME, required"`
22
23
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
23
-
Owner string `env:"OWNER, required"`
24
24
LogDids bool `env:"LOG_DIDS, default=true"`
25
25
26
26
// This disables signature verification so use with caution.
+150
-1008
knotserver/handler.go
+150
-1008
knotserver/handler.go
···
1
1
package knotserver
2
2
3
3
import (
4
-
"compress/gzip"
5
4
"context"
6
-
"crypto/sha256"
7
-
"encoding/json"
8
-
"errors"
9
5
"fmt"
10
-
"log"
6
+
"log/slog"
11
7
"net/http"
12
-
"net/url"
13
-
"path/filepath"
14
-
"strconv"
15
-
"strings"
16
-
"sync"
17
-
"time"
8
+
"runtime/debug"
18
9
19
-
securejoin "github.com/cyphar/filepath-securejoin"
20
-
"github.com/gliderlabs/ssh"
21
10
"github.com/go-chi/chi/v5"
22
-
"github.com/go-git/go-git/v5/plumbing"
23
-
"github.com/go-git/go-git/v5/plumbing/object"
11
+
"tangled.sh/tangled.sh/core/idresolver"
12
+
"tangled.sh/tangled.sh/core/jetstream"
13
+
"tangled.sh/tangled.sh/core/knotserver/config"
24
14
"tangled.sh/tangled.sh/core/knotserver/db"
25
-
"tangled.sh/tangled.sh/core/knotserver/git"
26
-
"tangled.sh/tangled.sh/core/types"
15
+
"tangled.sh/tangled.sh/core/knotserver/xrpc"
16
+
tlog "tangled.sh/tangled.sh/core/log"
17
+
"tangled.sh/tangled.sh/core/notifier"
18
+
"tangled.sh/tangled.sh/core/rbac"
27
19
)
28
20
29
-
func (h *Handle) Index(w http.ResponseWriter, r *http.Request) {
30
-
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
31
-
}
32
-
33
-
func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) {
34
-
w.Header().Set("Content-Type", "application/json")
35
-
36
-
capabilities := map[string]any{
37
-
"pull_requests": map[string]any{
38
-
"format_patch": true,
39
-
"patch_submissions": true,
40
-
"branch_submissions": true,
41
-
"fork_submissions": true,
42
-
},
43
-
"xrpc": true,
44
-
}
45
-
46
-
jsonData, err := json.Marshal(capabilities)
47
-
if err != nil {
48
-
http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError)
49
-
return
50
-
}
51
-
52
-
w.Write(jsonData)
53
-
}
54
-
55
-
func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) {
56
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
57
-
l := h.l.With("path", path, "handler", "RepoIndex")
58
-
ref := chi.URLParam(r, "ref")
59
-
ref, _ = url.PathUnescape(ref)
60
-
61
-
gr, err := git.Open(path, ref)
62
-
if err != nil {
63
-
plain, err2 := git.PlainOpen(path)
64
-
if err2 != nil {
65
-
l.Error("opening repo", "error", err2.Error())
66
-
notFound(w)
67
-
return
68
-
}
69
-
branches, _ := plain.Branches()
70
-
71
-
log.Println(err)
72
-
73
-
if errors.Is(err, plumbing.ErrReferenceNotFound) {
74
-
resp := types.RepoIndexResponse{
75
-
IsEmpty: true,
76
-
Branches: branches,
77
-
}
78
-
writeJSON(w, resp)
79
-
return
80
-
} else {
81
-
l.Error("opening repo", "error", err.Error())
82
-
notFound(w)
83
-
return
84
-
}
85
-
}
86
-
87
-
var (
88
-
commits []*object.Commit
89
-
total int
90
-
branches []types.Branch
91
-
files []types.NiceTree
92
-
tags []object.Tag
93
-
)
94
-
95
-
var wg sync.WaitGroup
96
-
errorsCh := make(chan error, 5)
97
-
98
-
wg.Add(1)
99
-
go func() {
100
-
defer wg.Done()
101
-
cs, err := gr.Commits(0, 60)
102
-
if err != nil {
103
-
errorsCh <- fmt.Errorf("commits: %w", err)
104
-
return
105
-
}
106
-
commits = cs
107
-
}()
108
-
109
-
wg.Add(1)
110
-
go func() {
111
-
defer wg.Done()
112
-
t, err := gr.TotalCommits()
113
-
if err != nil {
114
-
errorsCh <- fmt.Errorf("calculating total: %w", err)
115
-
return
116
-
}
117
-
total = t
118
-
}()
119
-
120
-
wg.Add(1)
121
-
go func() {
122
-
defer wg.Done()
123
-
bs, err := gr.Branches()
124
-
if err != nil {
125
-
errorsCh <- fmt.Errorf("fetching branches: %w", err)
126
-
return
127
-
}
128
-
branches = bs
129
-
}()
130
-
131
-
wg.Add(1)
132
-
go func() {
133
-
defer wg.Done()
134
-
ts, err := gr.Tags()
135
-
if err != nil {
136
-
errorsCh <- fmt.Errorf("fetching tags: %w", err)
137
-
return
138
-
}
139
-
tags = ts
140
-
}()
141
-
142
-
wg.Add(1)
143
-
go func() {
144
-
defer wg.Done()
145
-
fs, err := gr.FileTree(r.Context(), "")
146
-
if err != nil {
147
-
errorsCh <- fmt.Errorf("fetching filetree: %w", err)
148
-
return
149
-
}
150
-
files = fs
151
-
}()
21
+
type Handle struct {
22
+
c *config.Config
23
+
db *db.DB
24
+
jc *jetstream.JetstreamClient
25
+
e *rbac.Enforcer
26
+
l *slog.Logger
27
+
n *notifier.Notifier
28
+
resolver *idresolver.Resolver
152
29
153
-
wg.Wait()
154
-
close(errorsCh)
155
-
156
-
// show any errors
157
-
for err := range errorsCh {
158
-
l.Error("loading repo", "error", err.Error())
159
-
writeError(w, err.Error(), http.StatusInternalServerError)
160
-
return
161
-
}
162
-
163
-
rtags := []*types.TagReference{}
164
-
for _, tag := range tags {
165
-
var target *object.Tag
166
-
if tag.Target != plumbing.ZeroHash {
167
-
target = &tag
168
-
}
169
-
tr := types.TagReference{
170
-
Tag: target,
171
-
}
172
-
173
-
tr.Reference = types.Reference{
174
-
Name: tag.Name,
175
-
Hash: tag.Hash.String(),
176
-
}
177
-
178
-
if tag.Message != "" {
179
-
tr.Message = tag.Message
180
-
}
181
-
182
-
rtags = append(rtags, &tr)
183
-
}
184
-
185
-
var readmeContent string
186
-
var readmeFile string
187
-
for _, readme := range h.c.Repo.Readme {
188
-
content, _ := gr.FileContent(readme)
189
-
if len(content) > 0 {
190
-
readmeContent = string(content)
191
-
readmeFile = readme
192
-
}
193
-
}
194
-
195
-
if ref == "" {
196
-
mainBranch, err := gr.FindMainBranch()
197
-
if err != nil {
198
-
writeError(w, err.Error(), http.StatusInternalServerError)
199
-
l.Error("finding main branch", "error", err.Error())
200
-
return
201
-
}
202
-
ref = mainBranch
203
-
}
204
-
205
-
resp := types.RepoIndexResponse{
206
-
IsEmpty: false,
207
-
Ref: ref,
208
-
Commits: commits,
209
-
Description: getDescription(path),
210
-
Readme: readmeContent,
211
-
ReadmeFileName: readmeFile,
212
-
Files: files,
213
-
Branches: branches,
214
-
Tags: rtags,
215
-
TotalCommits: total,
216
-
}
217
-
218
-
writeJSON(w, resp)
30
+
// init is a channel that is closed when the knot has been initailized
31
+
// i.e. when the first user (knot owner) has been added.
32
+
init chan struct{}
33
+
knotInitialized bool
219
34
}
220
35
221
-
func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) {
222
-
treePath := chi.URLParam(r, "*")
223
-
ref := chi.URLParam(r, "ref")
224
-
ref, _ = url.PathUnescape(ref)
36
+
func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) {
37
+
r := chi.NewRouter()
225
38
226
-
l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath)
227
-
228
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
229
-
gr, err := git.Open(path, ref)
230
-
if err != nil {
231
-
notFound(w)
232
-
return
39
+
h := Handle{
40
+
c: c,
41
+
db: db,
42
+
e: e,
43
+
l: l,
44
+
jc: jc,
45
+
n: n,
46
+
resolver: idresolver.DefaultResolver(),
47
+
init: make(chan struct{}),
233
48
}
234
49
235
-
files, err := gr.FileTree(r.Context(), treePath)
50
+
err := e.AddKnot(rbac.ThisServer)
236
51
if err != nil {
237
-
writeError(w, err.Error(), http.StatusInternalServerError)
238
-
l.Error("file tree", "error", err.Error())
239
-
return
240
-
}
241
-
242
-
resp := types.RepoTreeResponse{
243
-
Ref: ref,
244
-
Parent: treePath,
245
-
Description: getDescription(path),
246
-
DotDot: filepath.Dir(treePath),
247
-
Files: files,
52
+
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
248
53
}
249
54
250
-
writeJSON(w, resp)
251
-
}
252
-
253
-
func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) {
254
-
treePath := chi.URLParam(r, "*")
255
-
ref := chi.URLParam(r, "ref")
256
-
ref, _ = url.PathUnescape(ref)
257
-
258
-
l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath)
259
-
260
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
261
-
gr, err := git.Open(path, ref)
55
+
// Check if the knot knows about any Dids;
56
+
// if it does, it is already initialized and we can repopulate the
57
+
// Jetstream subscriptions.
58
+
dids, err := db.GetAllDids()
262
59
if err != nil {
263
-
notFound(w)
264
-
return
60
+
return nil, fmt.Errorf("failed to get all Dids: %w", err)
265
61
}
266
62
267
-
contents, err := gr.RawContent(treePath)
268
-
if err != nil {
269
-
writeError(w, err.Error(), http.StatusBadRequest)
270
-
l.Error("file content", "error", err.Error())
271
-
return
272
-
}
273
-
274
-
mimeType := http.DetectContentType(contents)
275
-
276
-
// exception for svg
277
-
if filepath.Ext(treePath) == ".svg" {
278
-
mimeType = "image/svg+xml"
279
-
}
280
-
281
-
contentHash := sha256.Sum256(contents)
282
-
eTag := fmt.Sprintf("\"%x\"", contentHash)
283
-
284
-
// allow image, video, and text/plain files to be served directly
285
-
switch {
286
-
case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
287
-
if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
288
-
w.WriteHeader(http.StatusNotModified)
289
-
return
63
+
if len(dids) > 0 {
64
+
h.knotInitialized = true
65
+
close(h.init)
66
+
for _, d := range dids {
67
+
h.jc.AddDid(d)
290
68
}
291
-
w.Header().Set("ETag", eTag)
292
-
293
-
case strings.HasPrefix(mimeType, "text/plain"):
294
-
w.Header().Set("Cache-Control", "public, no-cache")
295
-
296
-
default:
297
-
l.Error("attempted to serve disallowed file type", "mimetype", mimeType)
298
-
writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden)
299
-
return
300
69
}
301
70
302
-
w.Header().Set("Content-Type", mimeType)
303
-
w.Write(contents)
304
-
}
305
-
306
-
func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) {
307
-
treePath := chi.URLParam(r, "*")
308
-
ref := chi.URLParam(r, "ref")
309
-
ref, _ = url.PathUnescape(ref)
310
-
311
-
l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath)
312
-
313
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
314
-
gr, err := git.Open(path, ref)
71
+
err = h.jc.StartJetstream(ctx, h.processMessages)
315
72
if err != nil {
316
-
notFound(w)
317
-
return
73
+
return nil, fmt.Errorf("failed to start jetstream: %w", err)
318
74
}
319
75
320
-
var isBinaryFile bool = false
321
-
contents, err := gr.FileContent(treePath)
322
-
if errors.Is(err, git.ErrBinaryFile) {
323
-
isBinaryFile = true
324
-
} else if errors.Is(err, object.ErrFileNotFound) {
325
-
notFound(w)
326
-
return
327
-
} else if err != nil {
328
-
writeError(w, err.Error(), http.StatusInternalServerError)
329
-
return
330
-
}
76
+
r.Get("/", h.Index)
77
+
r.Get("/capabilities", h.Capabilities)
78
+
r.Get("/version", h.Version)
79
+
r.Route("/{did}", func(r chi.Router) {
80
+
// Repo routes
81
+
r.Route("/{name}", func(r chi.Router) {
82
+
r.Route("/collaborator", func(r chi.Router) {
83
+
r.Use(h.VerifySignature)
84
+
r.Post("/add", h.AddRepoCollaborator)
85
+
})
331
86
332
-
bytes := []byte(contents)
333
-
// safe := string(sanitize(bytes))
334
-
sizeHint := len(bytes)
87
+
r.Route("/languages", func(r chi.Router) {
88
+
r.With(h.VerifySignature)
89
+
r.Get("/", h.RepoLanguages)
90
+
r.Get("/{ref}", h.RepoLanguages)
91
+
})
335
92
336
-
resp := types.RepoBlobResponse{
337
-
Ref: ref,
338
-
Contents: string(bytes),
339
-
Path: treePath,
340
-
IsBinary: isBinaryFile,
341
-
SizeHint: uint64(sizeHint),
342
-
}
343
-
344
-
h.showFile(resp, w, l)
345
-
}
346
-
347
-
func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) {
348
-
name := chi.URLParam(r, "name")
349
-
file := chi.URLParam(r, "file")
350
-
351
-
l := h.l.With("handler", "Archive", "name", name, "file", file)
93
+
r.Get("/", h.RepoIndex)
94
+
r.Get("/info/refs", h.InfoRefs)
95
+
r.Post("/git-upload-pack", h.UploadPack)
96
+
r.Post("/git-receive-pack", h.ReceivePack)
97
+
r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects
352
98
353
-
// TODO: extend this to add more files compression (e.g.: xz)
354
-
if !strings.HasSuffix(file, ".tar.gz") {
355
-
notFound(w)
356
-
return
357
-
}
99
+
r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef)
358
100
359
-
ref := strings.TrimSuffix(file, ".tar.gz")
101
+
r.Route("/merge", func(r chi.Router) {
102
+
r.With(h.VerifySignature)
103
+
r.Post("/", h.Merge)
104
+
r.Post("/check", h.MergeCheck)
105
+
})
360
106
361
-
unescapedRef, err := url.PathUnescape(ref)
362
-
if err != nil {
363
-
notFound(w)
364
-
return
365
-
}
107
+
r.Route("/tree/{ref}", func(r chi.Router) {
108
+
r.Get("/", h.RepoIndex)
109
+
r.Get("/*", h.RepoTree)
110
+
})
366
111
367
-
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
112
+
r.Route("/blob/{ref}", func(r chi.Router) {
113
+
r.Get("/*", h.Blob)
114
+
})
368
115
369
-
// This allows the browser to use a proper name for the file when
370
-
// downloading
371
-
filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename)
372
-
setContentDisposition(w, filename)
373
-
setGZipMIME(w)
116
+
r.Route("/raw/{ref}", func(r chi.Router) {
117
+
r.Get("/*", h.BlobRaw)
118
+
})
374
119
375
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
376
-
gr, err := git.Open(path, unescapedRef)
377
-
if err != nil {
378
-
notFound(w)
379
-
return
380
-
}
381
-
382
-
gw := gzip.NewWriter(w)
383
-
defer gw.Close()
384
-
385
-
prefix := fmt.Sprintf("%s-%s", name, safeRefFilename)
386
-
err = gr.WriteTar(gw, prefix)
387
-
if err != nil {
388
-
// once we start writing to the body we can't report error anymore
389
-
// so we are only left with printing the error.
390
-
l.Error("writing tar file", "error", err.Error())
391
-
return
392
-
}
393
-
394
-
err = gw.Flush()
395
-
if err != nil {
396
-
// once we start writing to the body we can't report error anymore
397
-
// so we are only left with printing the error.
398
-
l.Error("flushing?", "error", err.Error())
399
-
return
400
-
}
401
-
}
402
-
403
-
func (h *Handle) Log(w http.ResponseWriter, r *http.Request) {
404
-
ref := chi.URLParam(r, "ref")
405
-
ref, _ = url.PathUnescape(ref)
406
-
407
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
408
-
409
-
l := h.l.With("handler", "Log", "ref", ref, "path", path)
410
-
411
-
gr, err := git.Open(path, ref)
412
-
if err != nil {
413
-
notFound(w)
414
-
return
415
-
}
416
-
417
-
// Get page parameters
418
-
page := 1
419
-
pageSize := 30
420
-
421
-
if pageParam := r.URL.Query().Get("page"); pageParam != "" {
422
-
if p, err := strconv.Atoi(pageParam); err == nil && p > 0 {
423
-
page = p
424
-
}
425
-
}
426
-
427
-
if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" {
428
-
if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 {
429
-
pageSize = ps
430
-
}
431
-
}
432
-
433
-
// convert to offset/limit
434
-
offset := (page - 1) * pageSize
435
-
limit := pageSize
436
-
437
-
commits, err := gr.Commits(offset, limit)
438
-
if err != nil {
439
-
writeError(w, err.Error(), http.StatusInternalServerError)
440
-
l.Error("fetching commits", "error", err.Error())
441
-
return
442
-
}
443
-
444
-
total := len(commits)
445
-
446
-
resp := types.RepoLogResponse{
447
-
Commits: commits,
448
-
Ref: ref,
449
-
Description: getDescription(path),
450
-
Log: true,
451
-
Total: total,
452
-
Page: page,
453
-
PerPage: pageSize,
454
-
}
455
-
456
-
writeJSON(w, resp)
457
-
}
458
-
459
-
func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) {
460
-
ref := chi.URLParam(r, "ref")
461
-
ref, _ = url.PathUnescape(ref)
462
-
463
-
l := h.l.With("handler", "Diff", "ref", ref)
464
-
465
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
466
-
gr, err := git.Open(path, ref)
467
-
if err != nil {
468
-
notFound(w)
469
-
return
470
-
}
471
-
472
-
diff, err := gr.Diff()
473
-
if err != nil {
474
-
writeError(w, err.Error(), http.StatusInternalServerError)
475
-
l.Error("getting diff", "error", err.Error())
476
-
return
477
-
}
478
-
479
-
resp := types.RepoCommitResponse{
480
-
Ref: ref,
481
-
Diff: diff,
482
-
}
483
-
484
-
writeJSON(w, resp)
485
-
}
486
-
487
-
func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) {
488
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
489
-
l := h.l.With("handler", "Refs")
490
-
491
-
gr, err := git.Open(path, "")
492
-
if err != nil {
493
-
notFound(w)
494
-
return
495
-
}
496
-
497
-
tags, err := gr.Tags()
498
-
if err != nil {
499
-
// Non-fatal, we *should* have at least one branch to show.
500
-
l.Warn("getting tags", "error", err.Error())
501
-
}
502
-
503
-
rtags := []*types.TagReference{}
504
-
for _, tag := range tags {
505
-
var target *object.Tag
506
-
if tag.Target != plumbing.ZeroHash {
507
-
target = &tag
508
-
}
509
-
tr := types.TagReference{
510
-
Tag: target,
511
-
}
512
-
513
-
tr.Reference = types.Reference{
514
-
Name: tag.Name,
515
-
Hash: tag.Hash.String(),
516
-
}
517
-
518
-
if tag.Message != "" {
519
-
tr.Message = tag.Message
520
-
}
120
+
r.Get("/log/{ref}", h.Log)
121
+
r.Get("/archive/{file}", h.Archive)
122
+
r.Get("/commit/{ref}", h.Diff)
123
+
r.Get("/tags", h.Tags)
124
+
r.Route("/branches", func(r chi.Router) {
125
+
r.Get("/", h.Branches)
126
+
r.Get("/{branch}", h.Branch)
127
+
r.Route("/default", func(r chi.Router) {
128
+
r.Get("/", h.DefaultBranch)
129
+
r.With(h.VerifySignature).Put("/", h.SetDefaultBranch)
130
+
})
131
+
})
132
+
})
133
+
})
521
134
522
-
rtags = append(rtags, &tr)
523
-
}
135
+
// xrpc apis
136
+
r.Mount("/xrpc", h.XrpcRouter())
524
137
525
-
resp := types.RepoTagsResponse{
526
-
Tags: rtags,
527
-
}
138
+
// Create a new repository.
139
+
r.Route("/repo", func(r chi.Router) {
140
+
r.Use(h.VerifySignature)
141
+
r.Put("/new", h.NewRepo)
142
+
r.Delete("/", h.RemoveRepo)
143
+
r.Route("/fork", func(r chi.Router) {
144
+
r.Post("/", h.RepoFork)
145
+
r.Post("/sync/*", h.RepoForkSync)
146
+
r.Get("/sync/*", h.RepoForkAheadBehind)
147
+
})
148
+
})
528
149
529
-
writeJSON(w, resp)
530
-
}
150
+
r.Route("/member", func(r chi.Router) {
151
+
r.Use(h.VerifySignature)
152
+
r.Put("/add", h.AddMember)
153
+
})
531
154
532
-
func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) {
533
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
155
+
// Socket that streams git oplogs
156
+
r.Get("/events", h.Events)
534
157
535
-
gr, err := git.PlainOpen(path)
536
-
if err != nil {
537
-
notFound(w)
538
-
return
539
-
}
158
+
// Initialize the knot with an owner and public key.
159
+
r.With(h.VerifySignature).Post("/init", h.Init)
540
160
541
-
branches, _ := gr.Branches()
161
+
// Health check. Used for two-way verification with appview.
162
+
r.With(h.VerifySignature).Get("/health", h.Health)
542
163
543
-
resp := types.RepoBranchesResponse{
544
-
Branches: branches,
545
-
}
164
+
// All public keys on the knot.
165
+
r.Get("/keys", h.Keys)
546
166
547
-
writeJSON(w, resp)
167
+
return r, nil
548
168
}
549
169
550
-
func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) {
551
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
552
-
branchName := chi.URLParam(r, "branch")
553
-
branchName, _ = url.PathUnescape(branchName)
554
-
555
-
l := h.l.With("handler", "Branch")
556
-
557
-
gr, err := git.PlainOpen(path)
558
-
if err != nil {
559
-
notFound(w)
560
-
return
561
-
}
170
+
func (h *Handle) XrpcRouter() http.Handler {
171
+
logger := tlog.New("knots")
562
172
563
-
ref, err := gr.Branch(branchName)
564
-
if err != nil {
565
-
l.Error("getting branch", "error", err.Error())
566
-
writeError(w, err.Error(), http.StatusInternalServerError)
567
-
return
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,
568
181
}
569
-
570
-
commit, err := gr.Commit(ref.Hash())
571
-
if err != nil {
572
-
l.Error("getting commit object", "error", err.Error())
573
-
writeError(w, err.Error(), http.StatusInternalServerError)
574
-
return
575
-
}
576
-
577
-
defaultBranch, err := gr.FindMainBranch()
578
-
isDefault := false
579
-
if err != nil {
580
-
l.Error("getting default branch", "error", err.Error())
581
-
// do not quit though
582
-
} else if defaultBranch == branchName {
583
-
isDefault = true
584
-
}
585
-
586
-
resp := types.RepoBranchResponse{
587
-
Branch: types.Branch{
588
-
Reference: types.Reference{
589
-
Name: ref.Name().Short(),
590
-
Hash: ref.Hash().String(),
591
-
},
592
-
Commit: commit,
593
-
IsDefault: isDefault,
594
-
},
595
-
}
596
-
597
-
writeJSON(w, resp)
182
+
return xrpc.Router()
598
183
}
599
184
600
-
func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) {
601
-
l := h.l.With("handler", "Keys")
602
-
603
-
switch r.Method {
604
-
case http.MethodGet:
605
-
keys, err := h.db.GetAllPublicKeys()
606
-
if err != nil {
607
-
writeError(w, err.Error(), http.StatusInternalServerError)
608
-
l.Error("getting public keys", "error", err.Error())
609
-
return
610
-
}
611
-
612
-
data := make([]map[string]any, 0)
613
-
for _, key := range keys {
614
-
j := key.JSON()
615
-
data = append(data, j)
616
-
}
617
-
writeJSON(w, data)
618
-
return
185
+
// version is set during build time.
186
+
var version string
619
187
620
-
case http.MethodPut:
621
-
pk := db.PublicKey{}
622
-
if err := json.NewDecoder(r.Body).Decode(&pk); err != nil {
623
-
writeError(w, "invalid request body", http.StatusBadRequest)
188
+
func (h *Handle) Version(w http.ResponseWriter, r *http.Request) {
189
+
if version == "" {
190
+
info, ok := debug.ReadBuildInfo()
191
+
if !ok {
192
+
http.Error(w, "failed to read build info", http.StatusInternalServerError)
624
193
return
625
194
}
626
195
627
-
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key))
628
-
if err != nil {
629
-
writeError(w, "invalid pubkey", http.StatusBadRequest)
196
+
var modVer string
197
+
for _, mod := range info.Deps {
198
+
if mod.Path == "tangled.sh/tangled.sh/knotserver" {
199
+
version = mod.Version
200
+
break
201
+
}
630
202
}
631
203
632
-
if err := h.db.AddPublicKey(pk); err != nil {
633
-
writeError(w, err.Error(), http.StatusInternalServerError)
634
-
l.Error("adding public key", "error", err.Error())
635
-
return
204
+
if modVer == "" {
205
+
version = "unknown"
636
206
}
637
-
638
-
w.WriteHeader(http.StatusNoContent)
639
-
return
640
207
}
641
-
}
642
208
643
-
// func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) {
644
-
// l := h.l.With("handler", "RepoForkSync")
645
-
//
646
-
// data := struct {
647
-
// Did string `json:"did"`
648
-
// Source string `json:"source"`
649
-
// Name string `json:"name,omitempty"`
650
-
// HiddenRef string `json:"hiddenref"`
651
-
// }{}
652
-
//
653
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
654
-
// writeError(w, "invalid request body", http.StatusBadRequest)
655
-
// return
656
-
// }
657
-
//
658
-
// did := data.Did
659
-
// source := data.Source
660
-
//
661
-
// if did == "" || source == "" {
662
-
// l.Error("invalid request body, empty did or name")
663
-
// w.WriteHeader(http.StatusBadRequest)
664
-
// return
665
-
// }
666
-
//
667
-
// var name string
668
-
// if data.Name != "" {
669
-
// name = data.Name
670
-
// } else {
671
-
// name = filepath.Base(source)
672
-
// }
673
-
//
674
-
// branch := chi.URLParam(r, "branch")
675
-
// branch, _ = url.PathUnescape(branch)
676
-
//
677
-
// relativeRepoPath := filepath.Join(did, name)
678
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
679
-
//
680
-
// gr, err := git.PlainOpen(repoPath)
681
-
// if err != nil {
682
-
// log.Println(err)
683
-
// notFound(w)
684
-
// return
685
-
// }
686
-
//
687
-
// forkCommit, err := gr.ResolveRevision(branch)
688
-
// if err != nil {
689
-
// l.Error("error resolving ref revision", "msg", err.Error())
690
-
// writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest)
691
-
// return
692
-
// }
693
-
//
694
-
// sourceCommit, err := gr.ResolveRevision(data.HiddenRef)
695
-
// if err != nil {
696
-
// l.Error("error resolving hidden ref revision", "msg", err.Error())
697
-
// writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest)
698
-
// return
699
-
// }
700
-
//
701
-
// status := types.UpToDate
702
-
// if forkCommit.Hash.String() != sourceCommit.Hash.String() {
703
-
// isAncestor, err := forkCommit.IsAncestor(sourceCommit)
704
-
// if err != nil {
705
-
// log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err)
706
-
// return
707
-
// }
708
-
//
709
-
// if isAncestor {
710
-
// status = types.FastForwardable
711
-
// } else {
712
-
// status = types.Conflict
713
-
// }
714
-
// }
715
-
//
716
-
// w.Header().Set("Content-Type", "application/json")
717
-
// json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status})
718
-
// }
719
-
720
-
func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) {
721
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
722
-
ref := chi.URLParam(r, "ref")
723
-
ref, _ = url.PathUnescape(ref)
724
-
725
-
l := h.l.With("handler", "RepoLanguages")
726
-
727
-
gr, err := git.Open(repoPath, ref)
728
-
if err != nil {
729
-
l.Error("opening repo", "error", err.Error())
730
-
notFound(w)
731
-
return
732
-
}
733
-
734
-
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
735
-
defer cancel()
736
-
737
-
sizes, err := gr.AnalyzeLanguages(ctx)
738
-
if err != nil {
739
-
l.Error("failed to analyze languages", "error", err.Error())
740
-
writeError(w, err.Error(), http.StatusNoContent)
741
-
return
742
-
}
743
-
744
-
resp := types.RepoLanguageResponse{Languages: sizes}
745
-
746
-
writeJSON(w, resp)
747
-
}
748
-
749
-
// func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
750
-
// l := h.l.With("handler", "RepoForkSync")
751
-
//
752
-
// data := struct {
753
-
// Did string `json:"did"`
754
-
// Source string `json:"source"`
755
-
// Name string `json:"name,omitempty"`
756
-
// }{}
757
-
//
758
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
759
-
// writeError(w, "invalid request body", http.StatusBadRequest)
760
-
// return
761
-
// }
762
-
//
763
-
// did := data.Did
764
-
// source := data.Source
765
-
//
766
-
// if did == "" || source == "" {
767
-
// l.Error("invalid request body, empty did or name")
768
-
// w.WriteHeader(http.StatusBadRequest)
769
-
// return
770
-
// }
771
-
//
772
-
// var name string
773
-
// if data.Name != "" {
774
-
// name = data.Name
775
-
// } else {
776
-
// name = filepath.Base(source)
777
-
// }
778
-
//
779
-
// branch := chi.URLParam(r, "branch")
780
-
// branch, _ = url.PathUnescape(branch)
781
-
//
782
-
// relativeRepoPath := filepath.Join(did, name)
783
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
784
-
//
785
-
// gr, err := git.Open(repoPath, branch)
786
-
// if err != nil {
787
-
// log.Println(err)
788
-
// notFound(w)
789
-
// return
790
-
// }
791
-
//
792
-
// err = gr.Sync()
793
-
// if err != nil {
794
-
// l.Error("error syncing repo fork", "error", err.Error())
795
-
// writeError(w, err.Error(), http.StatusInternalServerError)
796
-
// return
797
-
// }
798
-
//
799
-
// w.WriteHeader(http.StatusNoContent)
800
-
// }
801
-
802
-
// func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) {
803
-
// l := h.l.With("handler", "RepoFork")
804
-
//
805
-
// data := struct {
806
-
// Did string `json:"did"`
807
-
// Source string `json:"source"`
808
-
// Name string `json:"name,omitempty"`
809
-
// }{}
810
-
//
811
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
812
-
// writeError(w, "invalid request body", http.StatusBadRequest)
813
-
// return
814
-
// }
815
-
//
816
-
// did := data.Did
817
-
// source := data.Source
818
-
//
819
-
// if did == "" || source == "" {
820
-
// l.Error("invalid request body, empty did or name")
821
-
// w.WriteHeader(http.StatusBadRequest)
822
-
// return
823
-
// }
824
-
//
825
-
// var name string
826
-
// if data.Name != "" {
827
-
// name = data.Name
828
-
// } else {
829
-
// name = filepath.Base(source)
830
-
// }
831
-
//
832
-
// relativeRepoPath := filepath.Join(did, name)
833
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
834
-
//
835
-
// err := git.Fork(repoPath, source)
836
-
// if err != nil {
837
-
// l.Error("forking repo", "error", err.Error())
838
-
// writeError(w, err.Error(), http.StatusInternalServerError)
839
-
// return
840
-
// }
841
-
//
842
-
// // add perms for this user to access the repo
843
-
// err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
844
-
// if err != nil {
845
-
// l.Error("adding repo permissions", "error", err.Error())
846
-
// writeError(w, err.Error(), http.StatusInternalServerError)
847
-
// return
848
-
// }
849
-
//
850
-
// hook.SetupRepo(
851
-
// hook.Config(
852
-
// hook.WithScanPath(h.c.Repo.ScanPath),
853
-
// hook.WithInternalApi(h.c.Server.InternalListenAddr),
854
-
// ),
855
-
// repoPath,
856
-
// )
857
-
//
858
-
// w.WriteHeader(http.StatusNoContent)
859
-
// }
860
-
861
-
// func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
862
-
// l := h.l.With("handler", "RemoveRepo")
863
-
//
864
-
// data := struct {
865
-
// Did string `json:"did"`
866
-
// Name string `json:"name"`
867
-
// }{}
868
-
//
869
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
870
-
// writeError(w, "invalid request body", http.StatusBadRequest)
871
-
// return
872
-
// }
873
-
//
874
-
// did := data.Did
875
-
// name := data.Name
876
-
//
877
-
// if did == "" || name == "" {
878
-
// l.Error("invalid request body, empty did or name")
879
-
// w.WriteHeader(http.StatusBadRequest)
880
-
// return
881
-
// }
882
-
//
883
-
// relativeRepoPath := filepath.Join(did, name)
884
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
885
-
// err := os.RemoveAll(repoPath)
886
-
// if err != nil {
887
-
// l.Error("removing repo", "error", err.Error())
888
-
// writeError(w, err.Error(), http.StatusInternalServerError)
889
-
// return
890
-
// }
891
-
//
892
-
// w.WriteHeader(http.StatusNoContent)
893
-
//
894
-
// }
895
-
896
-
// func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) {
897
-
// path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
898
-
//
899
-
// data := types.MergeRequest{}
900
-
//
901
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
902
-
// writeError(w, err.Error(), http.StatusBadRequest)
903
-
// h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err)
904
-
// return
905
-
// }
906
-
//
907
-
// mo := &git.MergeOptions{
908
-
// AuthorName: data.AuthorName,
909
-
// AuthorEmail: data.AuthorEmail,
910
-
// CommitBody: data.CommitBody,
911
-
// CommitMessage: data.CommitMessage,
912
-
// }
913
-
//
914
-
// patch := data.Patch
915
-
// branch := data.Branch
916
-
// gr, err := git.Open(path, branch)
917
-
// if err != nil {
918
-
// notFound(w)
919
-
// return
920
-
// }
921
-
//
922
-
// mo.FormatPatch = patchutil.IsFormatPatch(patch)
923
-
//
924
-
// if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil {
925
-
// var mergeErr *git.ErrMerge
926
-
// if errors.As(err, &mergeErr) {
927
-
// conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
928
-
// for i, conflict := range mergeErr.Conflicts {
929
-
// conflicts[i] = types.ConflictInfo{
930
-
// Filename: conflict.Filename,
931
-
// Reason: conflict.Reason,
932
-
// }
933
-
// }
934
-
// response := types.MergeCheckResponse{
935
-
// IsConflicted: true,
936
-
// Conflicts: conflicts,
937
-
// Message: mergeErr.Message,
938
-
// }
939
-
// writeConflict(w, response)
940
-
// h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr)
941
-
// } else {
942
-
// writeError(w, err.Error(), http.StatusBadRequest)
943
-
// h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error())
944
-
// }
945
-
// return
946
-
// }
947
-
//
948
-
// w.WriteHeader(http.StatusOK)
949
-
// }
950
-
951
-
// func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) {
952
-
// path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
953
-
//
954
-
// var data struct {
955
-
// Patch string `json:"patch"`
956
-
// Branch string `json:"branch"`
957
-
// }
958
-
//
959
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
960
-
// writeError(w, err.Error(), http.StatusBadRequest)
961
-
// h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err)
962
-
// return
963
-
// }
964
-
//
965
-
// patch := data.Patch
966
-
// branch := data.Branch
967
-
// gr, err := git.Open(path, branch)
968
-
// if err != nil {
969
-
// notFound(w)
970
-
// return
971
-
// }
972
-
//
973
-
// err = gr.MergeCheck([]byte(patch), branch)
974
-
// if err == nil {
975
-
// response := types.MergeCheckResponse{
976
-
// IsConflicted: false,
977
-
// }
978
-
// writeJSON(w, response)
979
-
// return
980
-
// }
981
-
//
982
-
// var mergeErr *git.ErrMerge
983
-
// if errors.As(err, &mergeErr) {
984
-
// conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
985
-
// for i, conflict := range mergeErr.Conflicts {
986
-
// conflicts[i] = types.ConflictInfo{
987
-
// Filename: conflict.Filename,
988
-
// Reason: conflict.Reason,
989
-
// }
990
-
// }
991
-
// response := types.MergeCheckResponse{
992
-
// IsConflicted: true,
993
-
// Conflicts: conflicts,
994
-
// Message: mergeErr.Message,
995
-
// }
996
-
// writeConflict(w, response)
997
-
// h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error())
998
-
// return
999
-
// }
1000
-
// writeError(w, err.Error(), http.StatusInternalServerError)
1001
-
// h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error())
1002
-
// }
1003
-
1004
-
func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) {
1005
-
rev1 := chi.URLParam(r, "rev1")
1006
-
rev1, _ = url.PathUnescape(rev1)
1007
-
1008
-
rev2 := chi.URLParam(r, "rev2")
1009
-
rev2, _ = url.PathUnescape(rev2)
1010
-
1011
-
l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2)
1012
-
1013
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1014
-
gr, err := git.PlainOpen(path)
1015
-
if err != nil {
1016
-
notFound(w)
1017
-
return
1018
-
}
1019
-
1020
-
commit1, err := gr.ResolveRevision(rev1)
1021
-
if err != nil {
1022
-
l.Error("error resolving revision 1", "msg", err.Error())
1023
-
writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest)
1024
-
return
1025
-
}
1026
-
1027
-
commit2, err := gr.ResolveRevision(rev2)
1028
-
if err != nil {
1029
-
l.Error("error resolving revision 2", "msg", err.Error())
1030
-
writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest)
1031
-
return
1032
-
}
1033
-
1034
-
rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2)
1035
-
if err != nil {
1036
-
l.Error("error comparing revisions", "msg", err.Error())
1037
-
writeError(w, "error comparing revisions", http.StatusBadRequest)
1038
-
return
1039
-
}
1040
-
1041
-
writeJSON(w, types.RepoFormatPatchResponse{
1042
-
Rev1: commit1.Hash.String(),
1043
-
Rev2: commit2.Hash.String(),
1044
-
FormatPatch: formatPatch,
1045
-
Patch: rawPatch,
1046
-
})
1047
-
}
1048
-
1049
-
func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) {
1050
-
l := h.l.With("handler", "DefaultBranch")
1051
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1052
-
1053
-
gr, err := git.Open(path, "")
1054
-
if err != nil {
1055
-
notFound(w)
1056
-
return
1057
-
}
1058
-
1059
-
branch, err := gr.FindMainBranch()
1060
-
if err != nil {
1061
-
writeError(w, err.Error(), http.StatusInternalServerError)
1062
-
l.Error("getting default branch", "error", err.Error())
1063
-
return
1064
-
}
1065
-
1066
-
writeJSON(w, types.RepoDefaultBranchResponse{
1067
-
Branch: branch,
1068
-
})
209
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
210
+
fmt.Fprintf(w, "knotserver/%s", version)
1069
211
}
+10
knotserver/http_util.go
+10
knotserver/http_util.go
···
20
20
func notFound(w http.ResponseWriter) {
21
21
writeError(w, "not found", http.StatusNotFound)
22
22
}
23
+
24
+
func writeMsg(w http.ResponseWriter, msg string) {
25
+
writeJSON(w, map[string]string{"msg": msg})
26
+
}
27
+
28
+
func writeConflict(w http.ResponseWriter, data interface{}) {
29
+
w.Header().Set("Content-Type", "application/json")
30
+
w.WriteHeader(http.StatusConflict)
31
+
json.NewEncoder(w).Encode(data)
32
+
}
+35
-20
knotserver/ingester.go
+35
-20
knotserver/ingester.go
···
8
8
"net/http"
9
9
"net/url"
10
10
"path/filepath"
11
+
"slices"
11
12
"strings"
12
13
13
14
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
73
74
}
74
75
l.Info("added member from firehose", "member", record.Subject)
75
76
76
-
if err := h.db.AddDid(record.Subject); err != nil {
77
+
if err := h.db.AddDid(did); err != nil {
77
78
l.Error("failed to add did", "error", err)
78
79
return fmt.Errorf("failed to add did: %w", err)
79
80
}
80
-
h.jc.AddDid(record.Subject)
81
+
h.jc.AddDid(did)
81
82
82
-
if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil {
83
+
if err := h.fetchAndAddKeys(ctx, did); err != nil {
83
84
return fmt.Errorf("failed to fetch and add keys: %w", err)
84
85
}
85
86
···
102
103
l = l.With("target_branch", record.TargetBranch)
103
104
104
105
if record.Source == nil {
105
-
return fmt.Errorf("ignoring pull record: not a branch-based pull request")
106
+
reason := "not a branch-based pull request"
107
+
l.Info("ignoring pull record", "reason", reason)
108
+
return fmt.Errorf("ignoring pull record: %s", reason)
106
109
}
107
110
108
111
if record.Source.Repo != nil {
109
-
return fmt.Errorf("ignoring pull record: fork based pull")
112
+
reason := "fork based pull"
113
+
l.Info("ignoring pull record", "reason", reason)
114
+
return fmt.Errorf("ignoring pull record: %s", reason)
115
+
}
116
+
117
+
allDids, err := h.db.GetAllDids()
118
+
if err != nil {
119
+
return err
120
+
}
121
+
122
+
// presently: we only process PRs from collaborators for pipelines
123
+
if !slices.Contains(allDids, did) {
124
+
reason := "not a known did"
125
+
l.Info("rejecting pull record", "reason", reason)
126
+
return fmt.Errorf("rejected pull record: %s, %s", reason, did)
110
127
}
111
128
112
129
repoAt, err := syntax.ParseATURI(record.TargetRepo)
113
130
if err != nil {
114
-
return fmt.Errorf("failed to parse ATURI: %w", err)
131
+
return err
115
132
}
116
133
117
134
// resolve this aturi to extract the repo record
···
127
144
128
145
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
129
146
if err != nil {
130
-
return fmt.Errorf("failed to resolver repo: %w", err)
147
+
return err
131
148
}
132
149
133
150
repo := resp.Value.Val.(*tangled.Repo)
134
151
135
152
if repo.Knot != h.c.Server.Hostname {
136
-
return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname)
153
+
reason := "not this knot"
154
+
l.Info("rejecting pull record", "reason", reason)
155
+
return fmt.Errorf("rejected pull record: %s", reason)
137
156
}
138
157
139
158
didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name)
140
159
if err != nil {
141
-
return fmt.Errorf("failed to construct relative repo path: %w", err)
160
+
return err
142
161
}
143
162
144
163
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
145
164
if err != nil {
146
-
return fmt.Errorf("failed to construct absolute repo path: %w", err)
165
+
return err
147
166
}
148
167
149
168
gr, err := git.Open(repoPath, record.Source.Branch)
150
169
if err != nil {
151
-
return fmt.Errorf("failed to open git repository: %w", err)
170
+
return err
152
171
}
153
172
154
173
workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir)
155
174
if err != nil {
156
-
return fmt.Errorf("failed to open workflow directory: %w", err)
175
+
return err
157
176
}
158
177
159
178
var pipeline workflow.RawPipeline
···
196
215
cp := compiler.Compile(compiler.Parse(pipeline))
197
216
eventJson, err := json.Marshal(cp)
198
217
if err != nil {
199
-
return fmt.Errorf("failed to marshal pipeline event: %w", err)
218
+
return err
200
219
}
201
220
202
221
// do not run empty pipelines
···
255
274
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
256
275
257
276
// check perms for this user
258
-
ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo)
259
-
if err != nil {
260
-
return fmt.Errorf("failed to check permissions: %w", err)
261
-
}
262
-
if !ok {
263
-
return fmt.Errorf("insufficient permissions: %s, %s, %s", did, "IsCollaboratorInviteAllowed", didSlashRepo)
277
+
if ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo); !ok || err != nil {
278
+
return fmt.Errorf("insufficient permissions: %w", err)
264
279
}
265
280
266
281
if err := h.db.AddDid(subjectId.DID.String()); err != nil {
···
302
317
return fmt.Errorf("error reading response body: %w", err)
303
318
}
304
319
305
-
for key := range strings.SplitSeq(string(plaintext), "\n") {
320
+
for _, key := range strings.Split(string(plaintext), "\n") {
306
321
if key == "" {
307
322
continue
308
323
}
+2
knotserver/internal.go
+2
knotserver/internal.go
+53
knotserver/middleware.go
+53
knotserver/middleware.go
···
1
+
package knotserver
2
+
3
+
import (
4
+
"crypto/hmac"
5
+
"crypto/sha256"
6
+
"encoding/hex"
7
+
"net/http"
8
+
"time"
9
+
)
10
+
11
+
func (h *Handle) VerifySignature(next http.Handler) http.Handler {
12
+
if h.c.Server.Dev {
13
+
return next
14
+
}
15
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16
+
signature := r.Header.Get("X-Signature")
17
+
if signature == "" || !h.verifyHMAC(signature, r) {
18
+
writeError(w, "signature verification failed", http.StatusForbidden)
19
+
return
20
+
}
21
+
next.ServeHTTP(w, r)
22
+
})
23
+
}
24
+
25
+
func (h *Handle) verifyHMAC(signature string, r *http.Request) bool {
26
+
secret := h.c.Server.Secret
27
+
timestamp := r.Header.Get("X-Timestamp")
28
+
if timestamp == "" {
29
+
return false
30
+
}
31
+
32
+
// Verify that the timestamp is not older than a minute
33
+
reqTime, err := time.Parse(time.RFC3339, timestamp)
34
+
if err != nil {
35
+
return false
36
+
}
37
+
if time.Since(reqTime) > time.Minute {
38
+
return false
39
+
}
40
+
41
+
message := r.Method + r.URL.Path + timestamp
42
+
43
+
mac := hmac.New(sha256.New, []byte(secret))
44
+
mac.Write([]byte(message))
45
+
expectedMAC := mac.Sum(nil)
46
+
47
+
signatureBytes, err := hex.DecodeString(signature)
48
+
if err != nil {
49
+
return false
50
+
}
51
+
52
+
return hmac.Equal(signatureBytes, expectedMAC)
53
+
}
+1292
-138
knotserver/routes.go
+1292
-138
knotserver/routes.go
···
1
1
package knotserver
2
2
3
3
import (
4
+
"compress/gzip"
4
5
"context"
6
+
"crypto/hmac"
7
+
"crypto/sha256"
8
+
"encoding/hex"
9
+
"encoding/json"
10
+
"errors"
5
11
"fmt"
6
-
"log/slog"
12
+
"log"
7
13
"net/http"
8
-
"runtime/debug"
14
+
"net/url"
15
+
"os"
16
+
"path/filepath"
17
+
"strconv"
18
+
"strings"
19
+
"sync"
20
+
"time"
9
21
22
+
securejoin "github.com/cyphar/filepath-securejoin"
23
+
"github.com/gliderlabs/ssh"
10
24
"github.com/go-chi/chi/v5"
11
-
"tangled.sh/tangled.sh/core/idresolver"
12
-
"tangled.sh/tangled.sh/core/jetstream"
13
-
"tangled.sh/tangled.sh/core/knotserver/config"
25
+
gogit "github.com/go-git/go-git/v5"
26
+
"github.com/go-git/go-git/v5/plumbing"
27
+
"github.com/go-git/go-git/v5/plumbing/object"
28
+
"tangled.sh/tangled.sh/core/hook"
14
29
"tangled.sh/tangled.sh/core/knotserver/db"
15
-
"tangled.sh/tangled.sh/core/knotserver/xrpc"
16
-
tlog "tangled.sh/tangled.sh/core/log"
17
-
"tangled.sh/tangled.sh/core/notifier"
30
+
"tangled.sh/tangled.sh/core/knotserver/git"
31
+
"tangled.sh/tangled.sh/core/patchutil"
18
32
"tangled.sh/tangled.sh/core/rbac"
19
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
33
+
"tangled.sh/tangled.sh/core/types"
20
34
)
21
35
22
-
type Handle struct {
23
-
c *config.Config
24
-
db *db.DB
25
-
jc *jetstream.JetstreamClient
26
-
e *rbac.Enforcer
27
-
l *slog.Logger
28
-
n *notifier.Notifier
29
-
resolver *idresolver.Resolver
36
+
func (h *Handle) Index(w http.ResponseWriter, r *http.Request) {
37
+
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
30
38
}
31
39
32
-
func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) {
33
-
r := chi.NewRouter()
40
+
func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) {
41
+
w.Header().Set("Content-Type", "application/json")
34
42
35
-
h := Handle{
36
-
c: c,
37
-
db: db,
38
-
e: e,
39
-
l: l,
40
-
jc: jc,
41
-
n: n,
42
-
resolver: idresolver.DefaultResolver(),
43
+
capabilities := map[string]any{
44
+
"pull_requests": map[string]any{
45
+
"format_patch": true,
46
+
"patch_submissions": true,
47
+
"branch_submissions": true,
48
+
"fork_submissions": true,
49
+
},
50
+
}
51
+
52
+
jsonData, err := json.Marshal(capabilities)
53
+
if err != nil {
54
+
http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError)
55
+
return
43
56
}
44
57
45
-
err := e.AddKnot(rbac.ThisServer)
58
+
w.Write(jsonData)
59
+
}
60
+
61
+
func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) {
62
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
63
+
l := h.l.With("path", path, "handler", "RepoIndex")
64
+
ref := chi.URLParam(r, "ref")
65
+
ref, _ = url.PathUnescape(ref)
66
+
67
+
gr, err := git.Open(path, ref)
46
68
if err != nil {
47
-
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
69
+
plain, err2 := git.PlainOpen(path)
70
+
if err2 != nil {
71
+
l.Error("opening repo", "error", err2.Error())
72
+
notFound(w)
73
+
return
74
+
}
75
+
branches, _ := plain.Branches()
76
+
77
+
log.Println(err)
78
+
79
+
if errors.Is(err, plumbing.ErrReferenceNotFound) {
80
+
resp := types.RepoIndexResponse{
81
+
IsEmpty: true,
82
+
Branches: branches,
83
+
}
84
+
writeJSON(w, resp)
85
+
return
86
+
} else {
87
+
l.Error("opening repo", "error", err.Error())
88
+
notFound(w)
89
+
return
90
+
}
91
+
}
92
+
93
+
var (
94
+
commits []*object.Commit
95
+
total int
96
+
branches []types.Branch
97
+
files []types.NiceTree
98
+
tags []object.Tag
99
+
)
100
+
101
+
var wg sync.WaitGroup
102
+
errorsCh := make(chan error, 5)
103
+
104
+
wg.Add(1)
105
+
go func() {
106
+
defer wg.Done()
107
+
cs, err := gr.Commits(0, 60)
108
+
if err != nil {
109
+
errorsCh <- fmt.Errorf("commits: %w", err)
110
+
return
111
+
}
112
+
commits = cs
113
+
}()
114
+
115
+
wg.Add(1)
116
+
go func() {
117
+
defer wg.Done()
118
+
t, err := gr.TotalCommits()
119
+
if err != nil {
120
+
errorsCh <- fmt.Errorf("calculating total: %w", err)
121
+
return
122
+
}
123
+
total = t
124
+
}()
125
+
126
+
wg.Add(1)
127
+
go func() {
128
+
defer wg.Done()
129
+
bs, err := gr.Branches()
130
+
if err != nil {
131
+
errorsCh <- fmt.Errorf("fetching branches: %w", err)
132
+
return
133
+
}
134
+
branches = bs
135
+
}()
136
+
137
+
wg.Add(1)
138
+
go func() {
139
+
defer wg.Done()
140
+
ts, err := gr.Tags()
141
+
if err != nil {
142
+
errorsCh <- fmt.Errorf("fetching tags: %w", err)
143
+
return
144
+
}
145
+
tags = ts
146
+
}()
147
+
148
+
wg.Add(1)
149
+
go func() {
150
+
defer wg.Done()
151
+
fs, err := gr.FileTree(r.Context(), "")
152
+
if err != nil {
153
+
errorsCh <- fmt.Errorf("fetching filetree: %w", err)
154
+
return
155
+
}
156
+
files = fs
157
+
}()
158
+
159
+
wg.Wait()
160
+
close(errorsCh)
161
+
162
+
// show any errors
163
+
for err := range errorsCh {
164
+
l.Error("loading repo", "error", err.Error())
165
+
writeError(w, err.Error(), http.StatusInternalServerError)
166
+
return
167
+
}
168
+
169
+
rtags := []*types.TagReference{}
170
+
for _, tag := range tags {
171
+
var target *object.Tag
172
+
if tag.Target != plumbing.ZeroHash {
173
+
target = &tag
174
+
}
175
+
tr := types.TagReference{
176
+
Tag: target,
177
+
}
178
+
179
+
tr.Reference = types.Reference{
180
+
Name: tag.Name,
181
+
Hash: tag.Hash.String(),
182
+
}
183
+
184
+
if tag.Message != "" {
185
+
tr.Message = tag.Message
186
+
}
187
+
188
+
rtags = append(rtags, &tr)
189
+
}
190
+
191
+
var readmeContent string
192
+
var readmeFile string
193
+
for _, readme := range h.c.Repo.Readme {
194
+
content, _ := gr.FileContent(readme)
195
+
if len(content) > 0 {
196
+
readmeContent = string(content)
197
+
readmeFile = readme
198
+
}
199
+
}
200
+
201
+
if ref == "" {
202
+
mainBranch, err := gr.FindMainBranch()
203
+
if err != nil {
204
+
writeError(w, err.Error(), http.StatusInternalServerError)
205
+
l.Error("finding main branch", "error", err.Error())
206
+
return
207
+
}
208
+
ref = mainBranch
209
+
}
210
+
211
+
resp := types.RepoIndexResponse{
212
+
IsEmpty: false,
213
+
Ref: ref,
214
+
Commits: commits,
215
+
Description: getDescription(path),
216
+
Readme: readmeContent,
217
+
ReadmeFileName: readmeFile,
218
+
Files: files,
219
+
Branches: branches,
220
+
Tags: rtags,
221
+
TotalCommits: total,
222
+
}
223
+
224
+
writeJSON(w, resp)
225
+
return
226
+
}
227
+
228
+
func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) {
229
+
treePath := chi.URLParam(r, "*")
230
+
ref := chi.URLParam(r, "ref")
231
+
ref, _ = url.PathUnescape(ref)
232
+
233
+
l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath)
234
+
235
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
236
+
gr, err := git.Open(path, ref)
237
+
if err != nil {
238
+
notFound(w)
239
+
return
240
+
}
241
+
242
+
files, err := gr.FileTree(r.Context(), treePath)
243
+
if err != nil {
244
+
writeError(w, err.Error(), http.StatusInternalServerError)
245
+
l.Error("file tree", "error", err.Error())
246
+
return
247
+
}
248
+
249
+
resp := types.RepoTreeResponse{
250
+
Ref: ref,
251
+
Parent: treePath,
252
+
Description: getDescription(path),
253
+
DotDot: filepath.Dir(treePath),
254
+
Files: files,
255
+
}
256
+
257
+
writeJSON(w, resp)
258
+
return
259
+
}
260
+
261
+
func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) {
262
+
treePath := chi.URLParam(r, "*")
263
+
ref := chi.URLParam(r, "ref")
264
+
ref, _ = url.PathUnescape(ref)
265
+
266
+
l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath)
267
+
268
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
269
+
gr, err := git.Open(path, ref)
270
+
if err != nil {
271
+
notFound(w)
272
+
return
273
+
}
274
+
275
+
contents, err := gr.RawContent(treePath)
276
+
if err != nil {
277
+
writeError(w, err.Error(), http.StatusBadRequest)
278
+
l.Error("file content", "error", err.Error())
279
+
return
280
+
}
281
+
282
+
mimeType := http.DetectContentType(contents)
283
+
284
+
// exception for svg
285
+
if filepath.Ext(treePath) == ".svg" {
286
+
mimeType = "image/svg+xml"
287
+
}
288
+
289
+
contentHash := sha256.Sum256(contents)
290
+
eTag := fmt.Sprintf("\"%x\"", contentHash)
291
+
292
+
// allow image, video, and text/plain files to be served directly
293
+
switch {
294
+
case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
295
+
if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
296
+
w.WriteHeader(http.StatusNotModified)
297
+
return
298
+
}
299
+
w.Header().Set("ETag", eTag)
300
+
301
+
case strings.HasPrefix(mimeType, "text/plain"):
302
+
w.Header().Set("Cache-Control", "public, no-cache")
303
+
304
+
default:
305
+
l.Error("attempted to serve disallowed file type", "mimetype", mimeType)
306
+
writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden)
307
+
return
308
+
}
309
+
310
+
w.Header().Set("Content-Type", mimeType)
311
+
w.Write(contents)
312
+
}
313
+
314
+
func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) {
315
+
treePath := chi.URLParam(r, "*")
316
+
ref := chi.URLParam(r, "ref")
317
+
ref, _ = url.PathUnescape(ref)
318
+
319
+
l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath)
320
+
321
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
322
+
gr, err := git.Open(path, ref)
323
+
if err != nil {
324
+
notFound(w)
325
+
return
326
+
}
327
+
328
+
var isBinaryFile bool = false
329
+
contents, err := gr.FileContent(treePath)
330
+
if errors.Is(err, git.ErrBinaryFile) {
331
+
isBinaryFile = true
332
+
} else if errors.Is(err, object.ErrFileNotFound) {
333
+
notFound(w)
334
+
return
335
+
} else if err != nil {
336
+
writeError(w, err.Error(), http.StatusInternalServerError)
337
+
return
338
+
}
339
+
340
+
bytes := []byte(contents)
341
+
// safe := string(sanitize(bytes))
342
+
sizeHint := len(bytes)
343
+
344
+
resp := types.RepoBlobResponse{
345
+
Ref: ref,
346
+
Contents: string(bytes),
347
+
Path: treePath,
348
+
IsBinary: isBinaryFile,
349
+
SizeHint: uint64(sizeHint),
48
350
}
49
351
50
-
// configure owner
51
-
if err = h.configureOwner(); err != nil {
52
-
return nil, err
352
+
h.showFile(resp, w, l)
353
+
}
354
+
355
+
func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) {
356
+
name := chi.URLParam(r, "name")
357
+
file := chi.URLParam(r, "file")
358
+
359
+
l := h.l.With("handler", "Archive", "name", name, "file", file)
360
+
361
+
// TODO: extend this to add more files compression (e.g.: xz)
362
+
if !strings.HasSuffix(file, ".tar.gz") {
363
+
notFound(w)
364
+
return
53
365
}
54
-
h.l.Info("owner set", "did", h.c.Server.Owner)
55
-
h.jc.AddDid(h.c.Server.Owner)
366
+
367
+
ref := strings.TrimSuffix(file, ".tar.gz")
56
368
57
-
// configure known-dids in jetstream consumer
58
-
dids, err := h.db.GetAllDids()
369
+
unescapedRef, err := url.PathUnescape(ref)
59
370
if err != nil {
60
-
return nil, fmt.Errorf("failed to get all dids: %w", err)
371
+
notFound(w)
372
+
return
61
373
}
62
-
for _, d := range dids {
63
-
jc.AddDid(d)
374
+
375
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
376
+
377
+
// This allows the browser to use a proper name for the file when
378
+
// downloading
379
+
filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename)
380
+
setContentDisposition(w, filename)
381
+
setGZipMIME(w)
382
+
383
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
384
+
gr, err := git.Open(path, unescapedRef)
385
+
if err != nil {
386
+
notFound(w)
387
+
return
64
388
}
65
389
66
-
err = h.jc.StartJetstream(ctx, h.processMessages)
390
+
gw := gzip.NewWriter(w)
391
+
defer gw.Close()
392
+
393
+
prefix := fmt.Sprintf("%s-%s", name, safeRefFilename)
394
+
err = gr.WriteTar(gw, prefix)
67
395
if err != nil {
68
-
return nil, fmt.Errorf("failed to start jetstream: %w", err)
396
+
// once we start writing to the body we can't report error anymore
397
+
// so we are only left with printing the error.
398
+
l.Error("writing tar file", "error", err.Error())
399
+
return
400
+
}
401
+
402
+
err = gw.Flush()
403
+
if err != nil {
404
+
// once we start writing to the body we can't report error anymore
405
+
// so we are only left with printing the error.
406
+
l.Error("flushing?", "error", err.Error())
407
+
return
69
408
}
409
+
}
410
+
411
+
func (h *Handle) Log(w http.ResponseWriter, r *http.Request) {
412
+
ref := chi.URLParam(r, "ref")
413
+
ref, _ = url.PathUnescape(ref)
70
414
71
-
r.Get("/", h.Index)
72
-
r.Get("/capabilities", h.Capabilities)
73
-
r.Get("/version", h.Version)
74
-
r.Get("/owner", func(w http.ResponseWriter, r *http.Request) {
75
-
w.Write([]byte(h.c.Server.Owner))
76
-
})
77
-
r.Route("/{did}", func(r chi.Router) {
78
-
// Repo routes
79
-
r.Route("/{name}", func(r chi.Router) {
415
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
80
416
81
-
r.Route("/languages", func(r chi.Router) {
82
-
r.Get("/", h.RepoLanguages)
83
-
r.Get("/{ref}", h.RepoLanguages)
84
-
})
417
+
l := h.l.With("handler", "Log", "ref", ref, "path", path)
85
418
86
-
r.Get("/", h.RepoIndex)
87
-
r.Get("/info/refs", h.InfoRefs)
88
-
r.Post("/git-upload-pack", h.UploadPack)
89
-
r.Post("/git-receive-pack", h.ReceivePack)
90
-
r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects
419
+
gr, err := git.Open(path, ref)
420
+
if err != nil {
421
+
notFound(w)
422
+
return
423
+
}
91
424
92
-
r.Route("/tree/{ref}", func(r chi.Router) {
93
-
r.Get("/", h.RepoIndex)
94
-
r.Get("/*", h.RepoTree)
95
-
})
425
+
// Get page parameters
426
+
page := 1
427
+
pageSize := 30
96
428
97
-
r.Route("/blob/{ref}", func(r chi.Router) {
98
-
r.Get("/*", h.Blob)
99
-
})
429
+
if pageParam := r.URL.Query().Get("page"); pageParam != "" {
430
+
if p, err := strconv.Atoi(pageParam); err == nil && p > 0 {
431
+
page = p
432
+
}
433
+
}
100
434
101
-
r.Route("/raw/{ref}", func(r chi.Router) {
102
-
r.Get("/*", h.BlobRaw)
103
-
})
435
+
if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" {
436
+
if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 {
437
+
pageSize = ps
438
+
}
439
+
}
104
440
105
-
r.Get("/log/{ref}", h.Log)
106
-
r.Get("/archive/{file}", h.Archive)
107
-
r.Get("/commit/{ref}", h.Diff)
108
-
r.Get("/tags", h.Tags)
109
-
r.Route("/branches", func(r chi.Router) {
110
-
r.Get("/", h.Branches)
111
-
r.Get("/{branch}", h.Branch)
112
-
r.Get("/default", h.DefaultBranch)
113
-
})
114
-
})
115
-
})
441
+
// convert to offset/limit
442
+
offset := (page - 1) * pageSize
443
+
limit := pageSize
116
444
117
-
// xrpc apis
118
-
r.Mount("/xrpc", h.XrpcRouter())
445
+
commits, err := gr.Commits(offset, limit)
446
+
if err != nil {
447
+
writeError(w, err.Error(), http.StatusInternalServerError)
448
+
l.Error("fetching commits", "error", err.Error())
449
+
return
450
+
}
119
451
120
-
// Socket that streams git oplogs
121
-
r.Get("/events", h.Events)
452
+
total := len(commits)
122
453
123
-
// All public keys on the knot.
124
-
r.Get("/keys", h.Keys)
454
+
resp := types.RepoLogResponse{
455
+
Commits: commits,
456
+
Ref: ref,
457
+
Description: getDescription(path),
458
+
Log: true,
459
+
Total: total,
460
+
Page: page,
461
+
PerPage: pageSize,
462
+
}
125
463
126
-
return r, nil
464
+
writeJSON(w, resp)
465
+
return
127
466
}
128
467
129
-
func (h *Handle) XrpcRouter() http.Handler {
130
-
logger := tlog.New("knots")
468
+
func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) {
469
+
ref := chi.URLParam(r, "ref")
470
+
ref, _ = url.PathUnescape(ref)
131
471
132
-
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
472
+
l := h.l.With("handler", "Diff", "ref", ref)
133
473
134
-
xrpc := &xrpc.Xrpc{
135
-
Config: h.c,
136
-
Db: h.db,
137
-
Ingester: h.jc,
138
-
Enforcer: h.e,
139
-
Logger: logger,
140
-
Notifier: h.n,
141
-
Resolver: h.resolver,
142
-
ServiceAuth: serviceAuth,
474
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
475
+
gr, err := git.Open(path, ref)
476
+
if err != nil {
477
+
notFound(w)
478
+
return
479
+
}
480
+
481
+
diff, err := gr.Diff()
482
+
if err != nil {
483
+
writeError(w, err.Error(), http.StatusInternalServerError)
484
+
l.Error("getting diff", "error", err.Error())
485
+
return
486
+
}
487
+
488
+
resp := types.RepoCommitResponse{
489
+
Ref: ref,
490
+
Diff: diff,
143
491
}
144
-
return xrpc.Router()
492
+
493
+
writeJSON(w, resp)
494
+
return
145
495
}
146
496
147
-
// version is set during build time.
148
-
var version string
497
+
func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) {
498
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
499
+
l := h.l.With("handler", "Refs")
500
+
501
+
gr, err := git.Open(path, "")
502
+
if err != nil {
503
+
notFound(w)
504
+
return
505
+
}
506
+
507
+
tags, err := gr.Tags()
508
+
if err != nil {
509
+
// Non-fatal, we *should* have at least one branch to show.
510
+
l.Warn("getting tags", "error", err.Error())
511
+
}
149
512
150
-
func (h *Handle) Version(w http.ResponseWriter, r *http.Request) {
151
-
if version == "" {
152
-
info, ok := debug.ReadBuildInfo()
153
-
if !ok {
154
-
http.Error(w, "failed to read build info", http.StatusInternalServerError)
155
-
return
513
+
rtags := []*types.TagReference{}
514
+
for _, tag := range tags {
515
+
var target *object.Tag
516
+
if tag.Target != plumbing.ZeroHash {
517
+
target = &tag
518
+
}
519
+
tr := types.TagReference{
520
+
Tag: target,
156
521
}
157
522
158
-
var modVer string
159
-
for _, mod := range info.Deps {
160
-
if mod.Path == "tangled.sh/tangled.sh/knotserver" {
161
-
version = mod.Version
162
-
break
163
-
}
523
+
tr.Reference = types.Reference{
524
+
Name: tag.Name,
525
+
Hash: tag.Hash.String(),
164
526
}
165
527
166
-
if modVer == "" {
167
-
version = "unknown"
528
+
if tag.Message != "" {
529
+
tr.Message = tag.Message
168
530
}
531
+
532
+
rtags = append(rtags, &tr)
169
533
}
170
534
171
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
172
-
fmt.Fprintf(w, "knotserver/%s", version)
535
+
resp := types.RepoTagsResponse{
536
+
Tags: rtags,
537
+
}
538
+
539
+
writeJSON(w, resp)
540
+
return
173
541
}
174
542
175
-
func (h *Handle) configureOwner() error {
176
-
cfgOwner := h.c.Server.Owner
543
+
func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) {
544
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
177
545
178
-
rbacDomain := "thisserver"
546
+
gr, err := git.PlainOpen(path)
547
+
if err != nil {
548
+
notFound(w)
549
+
return
550
+
}
179
551
180
-
existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain)
552
+
branches, _ := gr.Branches()
553
+
554
+
resp := types.RepoBranchesResponse{
555
+
Branches: branches,
556
+
}
557
+
558
+
writeJSON(w, resp)
559
+
return
560
+
}
561
+
562
+
func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) {
563
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
564
+
branchName := chi.URLParam(r, "branch")
565
+
branchName, _ = url.PathUnescape(branchName)
566
+
567
+
l := h.l.With("handler", "Branch")
568
+
569
+
gr, err := git.PlainOpen(path)
181
570
if err != nil {
182
-
return err
571
+
notFound(w)
572
+
return
573
+
}
574
+
575
+
ref, err := gr.Branch(branchName)
576
+
if err != nil {
577
+
l.Error("getting branch", "error", err.Error())
578
+
writeError(w, err.Error(), http.StatusInternalServerError)
579
+
return
580
+
}
581
+
582
+
commit, err := gr.Commit(ref.Hash())
583
+
if err != nil {
584
+
l.Error("getting commit object", "error", err.Error())
585
+
writeError(w, err.Error(), http.StatusInternalServerError)
586
+
return
587
+
}
588
+
589
+
defaultBranch, err := gr.FindMainBranch()
590
+
isDefault := false
591
+
if err != nil {
592
+
l.Error("getting default branch", "error", err.Error())
593
+
// do not quit though
594
+
} else if defaultBranch == branchName {
595
+
isDefault = true
183
596
}
184
597
185
-
switch len(existing) {
186
-
case 0:
187
-
// no owner configured, continue
188
-
case 1:
189
-
// find existing owner
190
-
existingOwner := existing[0]
598
+
resp := types.RepoBranchResponse{
599
+
Branch: types.Branch{
600
+
Reference: types.Reference{
601
+
Name: ref.Name().Short(),
602
+
Hash: ref.Hash().String(),
603
+
},
604
+
Commit: commit,
605
+
IsDefault: isDefault,
606
+
},
607
+
}
608
+
609
+
writeJSON(w, resp)
610
+
return
611
+
}
191
612
192
-
// no ownership change, this is okay
193
-
if existingOwner == h.c.Server.Owner {
194
-
break
613
+
func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) {
614
+
l := h.l.With("handler", "Keys")
615
+
616
+
switch r.Method {
617
+
case http.MethodGet:
618
+
keys, err := h.db.GetAllPublicKeys()
619
+
if err != nil {
620
+
writeError(w, err.Error(), http.StatusInternalServerError)
621
+
l.Error("getting public keys", "error", err.Error())
622
+
return
195
623
}
196
624
197
-
// remove existing owner
198
-
err = h.e.RemoveKnotOwner(rbacDomain, existingOwner)
625
+
data := make([]map[string]any, 0)
626
+
for _, key := range keys {
627
+
j := key.JSON()
628
+
data = append(data, j)
629
+
}
630
+
writeJSON(w, data)
631
+
return
632
+
633
+
case http.MethodPut:
634
+
pk := db.PublicKey{}
635
+
if err := json.NewDecoder(r.Body).Decode(&pk); err != nil {
636
+
writeError(w, "invalid request body", http.StatusBadRequest)
637
+
return
638
+
}
639
+
640
+
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key))
199
641
if err != nil {
200
-
return nil
642
+
writeError(w, "invalid pubkey", http.StatusBadRequest)
201
643
}
202
-
default:
203
-
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath)
644
+
645
+
if err := h.db.AddPublicKey(pk); err != nil {
646
+
writeError(w, err.Error(), http.StatusInternalServerError)
647
+
l.Error("adding public key", "error", err.Error())
648
+
return
649
+
}
650
+
651
+
w.WriteHeader(http.StatusNoContent)
652
+
return
653
+
}
654
+
}
655
+
656
+
func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) {
657
+
l := h.l.With("handler", "NewRepo")
658
+
659
+
data := struct {
660
+
Did string `json:"did"`
661
+
Name string `json:"name"`
662
+
DefaultBranch string `json:"default_branch,omitempty"`
663
+
}{}
664
+
665
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
666
+
writeError(w, "invalid request body", http.StatusBadRequest)
667
+
return
204
668
}
205
669
206
-
return h.e.AddKnotOwner(rbacDomain, cfgOwner)
670
+
if data.DefaultBranch == "" {
671
+
data.DefaultBranch = h.c.Repo.MainBranch
672
+
}
673
+
674
+
did := data.Did
675
+
name := data.Name
676
+
defaultBranch := data.DefaultBranch
677
+
678
+
if err := validateRepoName(name); err != nil {
679
+
l.Error("creating repo", "error", err.Error())
680
+
writeError(w, err.Error(), http.StatusBadRequest)
681
+
return
682
+
}
683
+
684
+
relativeRepoPath := filepath.Join(did, name)
685
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
686
+
err := git.InitBare(repoPath, defaultBranch)
687
+
if err != nil {
688
+
l.Error("initializing bare repo", "error", err.Error())
689
+
if errors.Is(err, gogit.ErrRepositoryAlreadyExists) {
690
+
writeError(w, "That repo already exists!", http.StatusConflict)
691
+
return
692
+
} else {
693
+
writeError(w, err.Error(), http.StatusInternalServerError)
694
+
return
695
+
}
696
+
}
697
+
698
+
// add perms for this user to access the repo
699
+
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
700
+
if err != nil {
701
+
l.Error("adding repo permissions", "error", err.Error())
702
+
writeError(w, err.Error(), http.StatusInternalServerError)
703
+
return
704
+
}
705
+
706
+
hook.SetupRepo(
707
+
hook.Config(
708
+
hook.WithScanPath(h.c.Repo.ScanPath),
709
+
hook.WithInternalApi(h.c.Server.InternalListenAddr),
710
+
),
711
+
repoPath,
712
+
)
713
+
714
+
w.WriteHeader(http.StatusNoContent)
715
+
}
716
+
717
+
func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) {
718
+
l := h.l.With("handler", "RepoForkAheadBehind")
719
+
720
+
data := struct {
721
+
Did string `json:"did"`
722
+
Source string `json:"source"`
723
+
Name string `json:"name,omitempty"`
724
+
HiddenRef string `json:"hiddenref"`
725
+
}{}
726
+
727
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
728
+
writeError(w, "invalid request body", http.StatusBadRequest)
729
+
return
730
+
}
731
+
732
+
did := data.Did
733
+
source := data.Source
734
+
735
+
if did == "" || source == "" {
736
+
l.Error("invalid request body, empty did or name")
737
+
w.WriteHeader(http.StatusBadRequest)
738
+
return
739
+
}
740
+
741
+
var name string
742
+
if data.Name != "" {
743
+
name = data.Name
744
+
} else {
745
+
name = filepath.Base(source)
746
+
}
747
+
748
+
branch := chi.URLParam(r, "branch")
749
+
branch, _ = url.PathUnescape(branch)
750
+
751
+
relativeRepoPath := filepath.Join(did, name)
752
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
753
+
754
+
gr, err := git.PlainOpen(repoPath)
755
+
if err != nil {
756
+
log.Println(err)
757
+
notFound(w)
758
+
return
759
+
}
760
+
761
+
forkCommit, err := gr.ResolveRevision(branch)
762
+
if err != nil {
763
+
l.Error("error resolving ref revision", "msg", err.Error())
764
+
writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest)
765
+
return
766
+
}
767
+
768
+
sourceCommit, err := gr.ResolveRevision(data.HiddenRef)
769
+
if err != nil {
770
+
l.Error("error resolving hidden ref revision", "msg", err.Error())
771
+
writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest)
772
+
return
773
+
}
774
+
775
+
status := types.UpToDate
776
+
if forkCommit.Hash.String() != sourceCommit.Hash.String() {
777
+
isAncestor, err := forkCommit.IsAncestor(sourceCommit)
778
+
if err != nil {
779
+
log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err)
780
+
return
781
+
}
782
+
783
+
if isAncestor {
784
+
status = types.FastForwardable
785
+
} else {
786
+
status = types.Conflict
787
+
}
788
+
}
789
+
790
+
w.Header().Set("Content-Type", "application/json")
791
+
json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status})
792
+
}
793
+
794
+
func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) {
795
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
796
+
ref := chi.URLParam(r, "ref")
797
+
ref, _ = url.PathUnescape(ref)
798
+
799
+
l := h.l.With("handler", "RepoLanguages")
800
+
801
+
gr, err := git.Open(repoPath, ref)
802
+
if err != nil {
803
+
l.Error("opening repo", "error", err.Error())
804
+
notFound(w)
805
+
return
806
+
}
807
+
808
+
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
809
+
defer cancel()
810
+
811
+
sizes, err := gr.AnalyzeLanguages(ctx)
812
+
if err != nil {
813
+
l.Error("failed to analyze languages", "error", err.Error())
814
+
writeError(w, err.Error(), http.StatusNoContent)
815
+
return
816
+
}
817
+
818
+
resp := types.RepoLanguageResponse{Languages: sizes}
819
+
820
+
writeJSON(w, resp)
821
+
}
822
+
823
+
func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
824
+
l := h.l.With("handler", "RepoForkSync")
825
+
826
+
data := struct {
827
+
Did string `json:"did"`
828
+
Source string `json:"source"`
829
+
Name string `json:"name,omitempty"`
830
+
}{}
831
+
832
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
833
+
writeError(w, "invalid request body", http.StatusBadRequest)
834
+
return
835
+
}
836
+
837
+
did := data.Did
838
+
source := data.Source
839
+
840
+
if did == "" || source == "" {
841
+
l.Error("invalid request body, empty did or name")
842
+
w.WriteHeader(http.StatusBadRequest)
843
+
return
844
+
}
845
+
846
+
var name string
847
+
if data.Name != "" {
848
+
name = data.Name
849
+
} else {
850
+
name = filepath.Base(source)
851
+
}
852
+
853
+
branch := chi.URLParam(r, "*")
854
+
branch, _ = url.PathUnescape(branch)
855
+
856
+
relativeRepoPath := filepath.Join(did, name)
857
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
858
+
859
+
gr, err := git.Open(repoPath, branch)
860
+
if err != nil {
861
+
log.Println(err)
862
+
notFound(w)
863
+
return
864
+
}
865
+
866
+
err = gr.Sync()
867
+
if err != nil {
868
+
l.Error("error syncing repo fork", "error", err.Error())
869
+
writeError(w, err.Error(), http.StatusInternalServerError)
870
+
return
871
+
}
872
+
873
+
w.WriteHeader(http.StatusNoContent)
874
+
}
875
+
876
+
func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) {
877
+
l := h.l.With("handler", "RepoFork")
878
+
879
+
data := struct {
880
+
Did string `json:"did"`
881
+
Source string `json:"source"`
882
+
Name string `json:"name,omitempty"`
883
+
}{}
884
+
885
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
886
+
writeError(w, "invalid request body", http.StatusBadRequest)
887
+
return
888
+
}
889
+
890
+
did := data.Did
891
+
source := data.Source
892
+
893
+
if did == "" || source == "" {
894
+
l.Error("invalid request body, empty did or name")
895
+
w.WriteHeader(http.StatusBadRequest)
896
+
return
897
+
}
898
+
899
+
var name string
900
+
if data.Name != "" {
901
+
name = data.Name
902
+
} else {
903
+
name = filepath.Base(source)
904
+
}
905
+
906
+
relativeRepoPath := filepath.Join(did, name)
907
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
908
+
909
+
err := git.Fork(repoPath, source)
910
+
if err != nil {
911
+
l.Error("forking repo", "error", err.Error())
912
+
writeError(w, err.Error(), http.StatusInternalServerError)
913
+
return
914
+
}
915
+
916
+
// add perms for this user to access the repo
917
+
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
918
+
if err != nil {
919
+
l.Error("adding repo permissions", "error", err.Error())
920
+
writeError(w, err.Error(), http.StatusInternalServerError)
921
+
return
922
+
}
923
+
924
+
hook.SetupRepo(
925
+
hook.Config(
926
+
hook.WithScanPath(h.c.Repo.ScanPath),
927
+
hook.WithInternalApi(h.c.Server.InternalListenAddr),
928
+
),
929
+
repoPath,
930
+
)
931
+
932
+
w.WriteHeader(http.StatusNoContent)
933
+
}
934
+
935
+
func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
936
+
l := h.l.With("handler", "RemoveRepo")
937
+
938
+
data := struct {
939
+
Did string `json:"did"`
940
+
Name string `json:"name"`
941
+
}{}
942
+
943
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
944
+
writeError(w, "invalid request body", http.StatusBadRequest)
945
+
return
946
+
}
947
+
948
+
did := data.Did
949
+
name := data.Name
950
+
951
+
if did == "" || name == "" {
952
+
l.Error("invalid request body, empty did or name")
953
+
w.WriteHeader(http.StatusBadRequest)
954
+
return
955
+
}
956
+
957
+
relativeRepoPath := filepath.Join(did, name)
958
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
959
+
err := os.RemoveAll(repoPath)
960
+
if err != nil {
961
+
l.Error("removing repo", "error", err.Error())
962
+
writeError(w, err.Error(), http.StatusInternalServerError)
963
+
return
964
+
}
965
+
966
+
w.WriteHeader(http.StatusNoContent)
967
+
968
+
}
969
+
func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) {
970
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
971
+
972
+
data := types.MergeRequest{}
973
+
974
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
975
+
writeError(w, err.Error(), http.StatusBadRequest)
976
+
h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err)
977
+
return
978
+
}
979
+
980
+
mo := &git.MergeOptions{
981
+
AuthorName: data.AuthorName,
982
+
AuthorEmail: data.AuthorEmail,
983
+
CommitBody: data.CommitBody,
984
+
CommitMessage: data.CommitMessage,
985
+
}
986
+
987
+
patch := data.Patch
988
+
branch := data.Branch
989
+
gr, err := git.Open(path, branch)
990
+
if err != nil {
991
+
notFound(w)
992
+
return
993
+
}
994
+
995
+
mo.FormatPatch = patchutil.IsFormatPatch(patch)
996
+
997
+
if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil {
998
+
var mergeErr *git.ErrMerge
999
+
if errors.As(err, &mergeErr) {
1000
+
conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
1001
+
for i, conflict := range mergeErr.Conflicts {
1002
+
conflicts[i] = types.ConflictInfo{
1003
+
Filename: conflict.Filename,
1004
+
Reason: conflict.Reason,
1005
+
}
1006
+
}
1007
+
response := types.MergeCheckResponse{
1008
+
IsConflicted: true,
1009
+
Conflicts: conflicts,
1010
+
Message: mergeErr.Message,
1011
+
}
1012
+
writeConflict(w, response)
1013
+
h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr)
1014
+
} else {
1015
+
writeError(w, err.Error(), http.StatusBadRequest)
1016
+
h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error())
1017
+
}
1018
+
return
1019
+
}
1020
+
1021
+
w.WriteHeader(http.StatusOK)
1022
+
}
1023
+
1024
+
func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) {
1025
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1026
+
1027
+
var data struct {
1028
+
Patch string `json:"patch"`
1029
+
Branch string `json:"branch"`
1030
+
}
1031
+
1032
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1033
+
writeError(w, err.Error(), http.StatusBadRequest)
1034
+
h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err)
1035
+
return
1036
+
}
1037
+
1038
+
patch := data.Patch
1039
+
branch := data.Branch
1040
+
gr, err := git.Open(path, branch)
1041
+
if err != nil {
1042
+
notFound(w)
1043
+
return
1044
+
}
1045
+
1046
+
err = gr.MergeCheck([]byte(patch), branch)
1047
+
if err == nil {
1048
+
response := types.MergeCheckResponse{
1049
+
IsConflicted: false,
1050
+
}
1051
+
writeJSON(w, response)
1052
+
return
1053
+
}
1054
+
1055
+
var mergeErr *git.ErrMerge
1056
+
if errors.As(err, &mergeErr) {
1057
+
conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
1058
+
for i, conflict := range mergeErr.Conflicts {
1059
+
conflicts[i] = types.ConflictInfo{
1060
+
Filename: conflict.Filename,
1061
+
Reason: conflict.Reason,
1062
+
}
1063
+
}
1064
+
response := types.MergeCheckResponse{
1065
+
IsConflicted: true,
1066
+
Conflicts: conflicts,
1067
+
Message: mergeErr.Message,
1068
+
}
1069
+
writeConflict(w, response)
1070
+
h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error())
1071
+
return
1072
+
}
1073
+
writeError(w, err.Error(), http.StatusInternalServerError)
1074
+
h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error())
1075
+
}
1076
+
1077
+
func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) {
1078
+
rev1 := chi.URLParam(r, "rev1")
1079
+
rev1, _ = url.PathUnescape(rev1)
1080
+
1081
+
rev2 := chi.URLParam(r, "rev2")
1082
+
rev2, _ = url.PathUnescape(rev2)
1083
+
1084
+
l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2)
1085
+
1086
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1087
+
gr, err := git.PlainOpen(path)
1088
+
if err != nil {
1089
+
notFound(w)
1090
+
return
1091
+
}
1092
+
1093
+
commit1, err := gr.ResolveRevision(rev1)
1094
+
if err != nil {
1095
+
l.Error("error resolving revision 1", "msg", err.Error())
1096
+
writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest)
1097
+
return
1098
+
}
1099
+
1100
+
commit2, err := gr.ResolveRevision(rev2)
1101
+
if err != nil {
1102
+
l.Error("error resolving revision 2", "msg", err.Error())
1103
+
writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest)
1104
+
return
1105
+
}
1106
+
1107
+
rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2)
1108
+
if err != nil {
1109
+
l.Error("error comparing revisions", "msg", err.Error())
1110
+
writeError(w, "error comparing revisions", http.StatusBadRequest)
1111
+
return
1112
+
}
1113
+
1114
+
writeJSON(w, types.RepoFormatPatchResponse{
1115
+
Rev1: commit1.Hash.String(),
1116
+
Rev2: commit2.Hash.String(),
1117
+
FormatPatch: formatPatch,
1118
+
Patch: rawPatch,
1119
+
})
1120
+
return
1121
+
}
1122
+
1123
+
func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) {
1124
+
l := h.l.With("handler", "NewHiddenRef")
1125
+
1126
+
forkRef := chi.URLParam(r, "forkRef")
1127
+
forkRef, _ = url.PathUnescape(forkRef)
1128
+
1129
+
remoteRef := chi.URLParam(r, "remoteRef")
1130
+
remoteRef, _ = url.PathUnescape(remoteRef)
1131
+
1132
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1133
+
gr, err := git.PlainOpen(path)
1134
+
if err != nil {
1135
+
notFound(w)
1136
+
return
1137
+
}
1138
+
1139
+
err = gr.TrackHiddenRemoteRef(forkRef, remoteRef)
1140
+
if err != nil {
1141
+
l.Error("error tracking hidden remote ref", "msg", err.Error())
1142
+
writeError(w, "error tracking hidden remote ref", http.StatusBadRequest)
1143
+
return
1144
+
}
1145
+
1146
+
w.WriteHeader(http.StatusNoContent)
1147
+
return
1148
+
}
1149
+
1150
+
func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) {
1151
+
l := h.l.With("handler", "AddMember")
1152
+
1153
+
data := struct {
1154
+
Did string `json:"did"`
1155
+
}{}
1156
+
1157
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1158
+
writeError(w, "invalid request body", http.StatusBadRequest)
1159
+
return
1160
+
}
1161
+
1162
+
did := data.Did
1163
+
1164
+
if err := h.db.AddDid(did); err != nil {
1165
+
l.Error("adding did", "error", err.Error())
1166
+
writeError(w, err.Error(), http.StatusInternalServerError)
1167
+
return
1168
+
}
1169
+
h.jc.AddDid(did)
1170
+
1171
+
if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil {
1172
+
l.Error("adding member", "error", err.Error())
1173
+
writeError(w, err.Error(), http.StatusInternalServerError)
1174
+
return
1175
+
}
1176
+
1177
+
if err := h.fetchAndAddKeys(r.Context(), did); err != nil {
1178
+
l.Error("fetching and adding keys", "error", err.Error())
1179
+
writeError(w, err.Error(), http.StatusInternalServerError)
1180
+
return
1181
+
}
1182
+
1183
+
w.WriteHeader(http.StatusNoContent)
1184
+
}
1185
+
1186
+
func (h *Handle) AddRepoCollaborator(w http.ResponseWriter, r *http.Request) {
1187
+
l := h.l.With("handler", "AddRepoCollaborator")
1188
+
1189
+
data := struct {
1190
+
Did string `json:"did"`
1191
+
}{}
1192
+
1193
+
ownerDid := chi.URLParam(r, "did")
1194
+
repo := chi.URLParam(r, "name")
1195
+
1196
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1197
+
writeError(w, "invalid request body", http.StatusBadRequest)
1198
+
return
1199
+
}
1200
+
1201
+
if err := h.db.AddDid(data.Did); err != nil {
1202
+
l.Error("adding did", "error", err.Error())
1203
+
writeError(w, err.Error(), http.StatusInternalServerError)
1204
+
return
1205
+
}
1206
+
h.jc.AddDid(data.Did)
1207
+
1208
+
repoName, _ := securejoin.SecureJoin(ownerDid, repo)
1209
+
if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil {
1210
+
l.Error("adding repo collaborator", "error", err.Error())
1211
+
writeError(w, err.Error(), http.StatusInternalServerError)
1212
+
return
1213
+
}
1214
+
1215
+
if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil {
1216
+
l.Error("fetching and adding keys", "error", err.Error())
1217
+
writeError(w, err.Error(), http.StatusInternalServerError)
1218
+
return
1219
+
}
1220
+
1221
+
w.WriteHeader(http.StatusNoContent)
1222
+
}
1223
+
1224
+
func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) {
1225
+
l := h.l.With("handler", "DefaultBranch")
1226
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1227
+
1228
+
gr, err := git.Open(path, "")
1229
+
if err != nil {
1230
+
notFound(w)
1231
+
return
1232
+
}
1233
+
1234
+
branch, err := gr.FindMainBranch()
1235
+
if err != nil {
1236
+
writeError(w, err.Error(), http.StatusInternalServerError)
1237
+
l.Error("getting default branch", "error", err.Error())
1238
+
return
1239
+
}
1240
+
1241
+
writeJSON(w, types.RepoDefaultBranchResponse{
1242
+
Branch: branch,
1243
+
})
1244
+
}
1245
+
1246
+
func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1247
+
l := h.l.With("handler", "SetDefaultBranch")
1248
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1249
+
1250
+
data := struct {
1251
+
Branch string `json:"branch"`
1252
+
}{}
1253
+
1254
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1255
+
writeError(w, err.Error(), http.StatusBadRequest)
1256
+
return
1257
+
}
1258
+
1259
+
gr, err := git.PlainOpen(path)
1260
+
if err != nil {
1261
+
notFound(w)
1262
+
return
1263
+
}
1264
+
1265
+
err = gr.SetDefaultBranch(data.Branch)
1266
+
if err != nil {
1267
+
writeError(w, err.Error(), http.StatusInternalServerError)
1268
+
l.Error("setting default branch", "error", err.Error())
1269
+
return
1270
+
}
1271
+
1272
+
w.WriteHeader(http.StatusNoContent)
1273
+
}
1274
+
1275
+
func (h *Handle) Init(w http.ResponseWriter, r *http.Request) {
1276
+
l := h.l.With("handler", "Init")
1277
+
1278
+
if h.knotInitialized {
1279
+
writeError(w, "knot already initialized", http.StatusConflict)
1280
+
return
1281
+
}
1282
+
1283
+
data := struct {
1284
+
Did string `json:"did"`
1285
+
}{}
1286
+
1287
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1288
+
l.Error("failed to decode request body", "error", err.Error())
1289
+
writeError(w, "invalid request body", http.StatusBadRequest)
1290
+
return
1291
+
}
1292
+
1293
+
if data.Did == "" {
1294
+
l.Error("empty DID in request", "did", data.Did)
1295
+
writeError(w, "did is empty", http.StatusBadRequest)
1296
+
return
1297
+
}
1298
+
1299
+
if err := h.db.AddDid(data.Did); err != nil {
1300
+
l.Error("failed to add DID", "error", err.Error())
1301
+
writeError(w, err.Error(), http.StatusInternalServerError)
1302
+
return
1303
+
}
1304
+
h.jc.AddDid(data.Did)
1305
+
1306
+
if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil {
1307
+
l.Error("adding owner", "error", err.Error())
1308
+
writeError(w, err.Error(), http.StatusInternalServerError)
1309
+
return
1310
+
}
1311
+
1312
+
if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil {
1313
+
l.Error("fetching and adding keys", "error", err.Error())
1314
+
writeError(w, err.Error(), http.StatusInternalServerError)
1315
+
return
1316
+
}
1317
+
1318
+
close(h.init)
1319
+
1320
+
mac := hmac.New(sha256.New, []byte(h.c.Server.Secret))
1321
+
mac.Write([]byte("ok"))
1322
+
w.Header().Add("X-Signature", hex.EncodeToString(mac.Sum(nil)))
1323
+
1324
+
w.WriteHeader(http.StatusNoContent)
1325
+
}
1326
+
1327
+
func (h *Handle) Health(w http.ResponseWriter, r *http.Request) {
1328
+
w.Write([]byte("ok"))
1329
+
}
1330
+
1331
+
func validateRepoName(name string) error {
1332
+
// check for path traversal attempts
1333
+
if name == "." || name == ".." ||
1334
+
strings.Contains(name, "/") || strings.Contains(name, "\\") {
1335
+
return fmt.Errorf("Repository name contains invalid path characters")
1336
+
}
1337
+
1338
+
// check for sequences that could be used for traversal when normalized
1339
+
if strings.Contains(name, "./") || strings.Contains(name, "../") ||
1340
+
strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
1341
+
return fmt.Errorf("Repository name contains invalid path sequence")
1342
+
}
1343
+
1344
+
// then continue with character validation
1345
+
for _, char := range name {
1346
+
if !((char >= 'a' && char <= 'z') ||
1347
+
(char >= 'A' && char <= 'Z') ||
1348
+
(char >= '0' && char <= '9') ||
1349
+
char == '-' || char == '_' || char == '.') {
1350
+
return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
1351
+
}
1352
+
}
1353
+
1354
+
// additional check to prevent multiple sequential dots
1355
+
if strings.Contains(name, "..") {
1356
+
return fmt.Errorf("Repository name cannot contain sequential dots")
1357
+
}
1358
+
1359
+
// if all checks pass
1360
+
return nil
207
1361
}
-156
knotserver/xrpc/create_repo.go
-156
knotserver/xrpc/create_repo.go
···
1
-
package xrpc
2
-
3
-
import (
4
-
"encoding/json"
5
-
"errors"
6
-
"fmt"
7
-
"net/http"
8
-
"path/filepath"
9
-
"strings"
10
-
11
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
12
-
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"github.com/bluesky-social/indigo/xrpc"
14
-
securejoin "github.com/cyphar/filepath-securejoin"
15
-
gogit "github.com/go-git/go-git/v5"
16
-
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/hook"
18
-
"tangled.sh/tangled.sh/core/knotserver/git"
19
-
"tangled.sh/tangled.sh/core/rbac"
20
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
21
-
)
22
-
23
-
func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
24
-
l := h.Logger.With("handler", "NewRepo")
25
-
fail := func(e xrpcerr.XrpcError) {
26
-
l.Error("failed", "kind", e.Tag, "error", e.Message)
27
-
writeError(w, e, http.StatusBadRequest)
28
-
}
29
-
30
-
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
31
-
if !ok {
32
-
fail(xrpcerr.MissingActorDidError)
33
-
return
34
-
}
35
-
36
-
isMember, err := h.Enforcer.IsRepoCreateAllowed(actorDid.String(), rbac.ThisServer)
37
-
if err != nil {
38
-
fail(xrpcerr.GenericError(err))
39
-
return
40
-
}
41
-
if !isMember {
42
-
fail(xrpcerr.AccessControlError(actorDid.String()))
43
-
return
44
-
}
45
-
46
-
var data tangled.RepoCreate_Input
47
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
48
-
fail(xrpcerr.GenericError(err))
49
-
return
50
-
}
51
-
52
-
rkey := data.Rkey
53
-
54
-
ident, err := h.Resolver.ResolveIdent(r.Context(), actorDid.String())
55
-
if err != nil || ident.Handle.IsInvalidHandle() {
56
-
fail(xrpcerr.GenericError(err))
57
-
return
58
-
}
59
-
60
-
xrpcc := xrpc.Client{
61
-
Host: ident.PDSEndpoint(),
62
-
}
63
-
64
-
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey)
65
-
if err != nil {
66
-
fail(xrpcerr.GenericError(err))
67
-
return
68
-
}
69
-
70
-
repo := resp.Value.Val.(*tangled.Repo)
71
-
72
-
defaultBranch := h.Config.Repo.MainBranch
73
-
if data.DefaultBranch != nil && *data.DefaultBranch != "" {
74
-
defaultBranch = *data.DefaultBranch
75
-
}
76
-
77
-
if err := validateRepoName(repo.Name); err != nil {
78
-
l.Error("creating repo", "error", err.Error())
79
-
fail(xrpcerr.GenericError(err))
80
-
return
81
-
}
82
-
83
-
relativeRepoPath := filepath.Join(actorDid.String(), repo.Name)
84
-
repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath)
85
-
86
-
if data.Source != nil && *data.Source != "" {
87
-
err = git.Fork(repoPath, *data.Source)
88
-
if err != nil {
89
-
l.Error("forking repo", "error", err.Error())
90
-
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
91
-
return
92
-
}
93
-
} else {
94
-
err = git.InitBare(repoPath, defaultBranch)
95
-
if err != nil {
96
-
l.Error("initializing bare repo", "error", err.Error())
97
-
if errors.Is(err, gogit.ErrRepositoryAlreadyExists) {
98
-
fail(xrpcerr.RepoExistsError("repository already exists"))
99
-
return
100
-
} else {
101
-
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
102
-
return
103
-
}
104
-
}
105
-
}
106
-
107
-
// add perms for this user to access the repo
108
-
err = h.Enforcer.AddRepo(actorDid.String(), rbac.ThisServer, relativeRepoPath)
109
-
if err != nil {
110
-
l.Error("adding repo permissions", "error", err.Error())
111
-
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
112
-
return
113
-
}
114
-
115
-
hook.SetupRepo(
116
-
hook.Config(
117
-
hook.WithScanPath(h.Config.Repo.ScanPath),
118
-
hook.WithInternalApi(h.Config.Server.InternalListenAddr),
119
-
),
120
-
repoPath,
121
-
)
122
-
123
-
w.WriteHeader(http.StatusOK)
124
-
}
125
-
126
-
func validateRepoName(name string) error {
127
-
// check for path traversal attempts
128
-
if name == "." || name == ".." ||
129
-
strings.Contains(name, "/") || strings.Contains(name, "\\") {
130
-
return fmt.Errorf("Repository name contains invalid path characters")
131
-
}
132
-
133
-
// check for sequences that could be used for traversal when normalized
134
-
if strings.Contains(name, "./") || strings.Contains(name, "../") ||
135
-
strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
136
-
return fmt.Errorf("Repository name contains invalid path sequence")
137
-
}
138
-
139
-
// then continue with character validation
140
-
for _, char := range name {
141
-
if !((char >= 'a' && char <= 'z') ||
142
-
(char >= 'A' && char <= 'Z') ||
143
-
(char >= '0' && char <= '9') ||
144
-
char == '-' || char == '_' || char == '.') {
145
-
return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
146
-
}
147
-
}
148
-
149
-
// additional check to prevent multiple sequential dots
150
-
if strings.Contains(name, "..") {
151
-
return fmt.Errorf("Repository name cannot contain sequential dots")
152
-
}
153
-
154
-
// if all checks pass
155
-
return nil
156
-
}
-96
knotserver/xrpc/delete_repo.go
-96
knotserver/xrpc/delete_repo.go
···
1
-
package xrpc
2
-
3
-
import (
4
-
"encoding/json"
5
-
"fmt"
6
-
"net/http"
7
-
"os"
8
-
"path/filepath"
9
-
10
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
-
"github.com/bluesky-social/indigo/atproto/syntax"
12
-
"github.com/bluesky-social/indigo/xrpc"
13
-
securejoin "github.com/cyphar/filepath-securejoin"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
15
-
"tangled.sh/tangled.sh/core/rbac"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
-
)
18
-
19
-
func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
20
-
l := x.Logger.With("handler", "DeleteRepo")
21
-
fail := func(e xrpcerr.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(xrpcerr.MissingActorDidError)
29
-
return
30
-
}
31
-
32
-
var data tangled.RepoDelete_Input
33
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
34
-
fail(xrpcerr.GenericError(err))
35
-
return
36
-
}
37
-
38
-
did := data.Did
39
-
name := data.Name
40
-
rkey := data.Rkey
41
-
42
-
if did == "" || name == "" {
43
-
fail(xrpcerr.GenericError(fmt.Errorf("did and name are required")))
44
-
return
45
-
}
46
-
47
-
ident, err := x.Resolver.ResolveIdent(r.Context(), actorDid.String())
48
-
if err != nil || ident.Handle.IsInvalidHandle() {
49
-
fail(xrpcerr.GenericError(err))
50
-
return
51
-
}
52
-
53
-
xrpcc := xrpc.Client{
54
-
Host: ident.PDSEndpoint(),
55
-
}
56
-
57
-
// ensure that the record does not exists
58
-
_, err = comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey)
59
-
if err == nil {
60
-
fail(xrpcerr.RecordExistsError(rkey))
61
-
return
62
-
}
63
-
64
-
relativeRepoPath := filepath.Join(did, name)
65
-
isDeleteAllowed, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath)
66
-
if err != nil {
67
-
fail(xrpcerr.GenericError(err))
68
-
return
69
-
}
70
-
if !isDeleteAllowed {
71
-
fail(xrpcerr.AccessControlError(actorDid.String()))
72
-
return
73
-
}
74
-
75
-
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
76
-
if err != nil {
77
-
fail(xrpcerr.GenericError(err))
78
-
return
79
-
}
80
-
81
-
err = os.RemoveAll(repoPath)
82
-
if err != nil {
83
-
l.Error("deleting repo", "error", err.Error())
84
-
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
85
-
return
86
-
}
87
-
88
-
err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath)
89
-
if err != nil {
90
-
l.Error("failed to delete repo from enforcer", "error", err.Error())
91
-
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
92
-
return
93
-
}
94
-
95
-
w.WriteHeader(http.StatusOK)
96
-
}
-111
knotserver/xrpc/fork_status.go
-111
knotserver/xrpc/fork_status.go
···
1
-
package xrpc
2
-
3
-
import (
4
-
"encoding/json"
5
-
"fmt"
6
-
"net/http"
7
-
"path/filepath"
8
-
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/types"
15
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
-
)
17
-
18
-
func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
19
-
l := x.Logger.With("handler", "ForkStatus")
20
-
fail := func(e xrpcerr.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(xrpcerr.MissingActorDidError)
28
-
return
29
-
}
30
-
31
-
var data tangled.RepoForkStatus_Input
32
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
33
-
fail(xrpcerr.GenericError(err))
34
-
return
35
-
}
36
-
37
-
did := data.Did
38
-
source := data.Source
39
-
branch := data.Branch
40
-
hiddenRef := data.HiddenRef
41
-
42
-
if did == "" || source == "" || branch == "" || hiddenRef == "" {
43
-
fail(xrpcerr.GenericError(fmt.Errorf("did, source, branch, and hiddenRef are required")))
44
-
return
45
-
}
46
-
47
-
var name string
48
-
if data.Name != "" {
49
-
name = data.Name
50
-
} else {
51
-
name = filepath.Base(source)
52
-
}
53
-
54
-
relativeRepoPath := filepath.Join(did, name)
55
-
56
-
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil {
57
-
l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath)
58
-
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
59
-
return
60
-
}
61
-
62
-
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
63
-
if err != nil {
64
-
fail(xrpcerr.GenericError(err))
65
-
return
66
-
}
67
-
68
-
gr, err := git.PlainOpen(repoPath)
69
-
if err != nil {
70
-
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
71
-
return
72
-
}
73
-
74
-
forkCommit, err := gr.ResolveRevision(branch)
75
-
if err != nil {
76
-
l.Error("error resolving ref revision", "msg", err.Error())
77
-
fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", branch, err)))
78
-
return
79
-
}
80
-
81
-
sourceCommit, err := gr.ResolveRevision(hiddenRef)
82
-
if err != nil {
83
-
l.Error("error resolving hidden ref revision", "msg", err.Error())
84
-
fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", hiddenRef, err)))
85
-
return
86
-
}
87
-
88
-
status := types.UpToDate
89
-
if forkCommit.Hash.String() != sourceCommit.Hash.String() {
90
-
isAncestor, err := forkCommit.IsAncestor(sourceCommit)
91
-
if err != nil {
92
-
l.Error("error checking ancestor relationship", "error", err.Error())
93
-
fail(xrpcerr.GenericError(fmt.Errorf("error resolving whether %s is ancestor of %s: %w", branch, hiddenRef, err)))
94
-
return
95
-
}
96
-
97
-
if isAncestor {
98
-
status = types.FastForwardable
99
-
} else {
100
-
status = types.Conflict
101
-
}
102
-
}
103
-
104
-
response := tangled.RepoForkStatus_Output{
105
-
Status: int64(status),
106
-
}
107
-
108
-
w.Header().Set("Content-Type", "application/json")
109
-
w.WriteHeader(http.StatusOK)
110
-
json.NewEncoder(w).Encode(response)
111
-
}
-73
knotserver/xrpc/fork_sync.go
-73
knotserver/xrpc/fork_sync.go
···
1
-
package xrpc
2
-
3
-
import (
4
-
"encoding/json"
5
-
"fmt"
6
-
"net/http"
7
-
"path/filepath"
8
-
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
15
-
)
16
-
17
-
func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
18
-
l := x.Logger.With("handler", "ForkSync")
19
-
fail := func(e xrpcerr.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(xrpcerr.MissingActorDidError)
27
-
return
28
-
}
29
-
30
-
var data tangled.RepoForkSync_Input
31
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
32
-
fail(xrpcerr.GenericError(err))
33
-
return
34
-
}
35
-
36
-
did := data.Did
37
-
name := data.Name
38
-
branch := data.Branch
39
-
40
-
if did == "" || name == "" {
41
-
fail(xrpcerr.GenericError(fmt.Errorf("did, name are required")))
42
-
return
43
-
}
44
-
45
-
relativeRepoPath := filepath.Join(did, name)
46
-
47
-
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil {
48
-
l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath)
49
-
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
50
-
return
51
-
}
52
-
53
-
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
54
-
if err != nil {
55
-
fail(xrpcerr.GenericError(err))
56
-
return
57
-
}
58
-
59
-
gr, err := git.Open(repoPath, branch)
60
-
if err != nil {
61
-
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
62
-
return
63
-
}
64
-
65
-
err = gr.Sync()
66
-
if err != nil {
67
-
l.Error("error syncing repo fork", "error", err.Error())
68
-
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
69
-
return
70
-
}
71
-
72
-
w.WriteHeader(http.StatusOK)
73
-
}
-112
knotserver/xrpc/merge.go
-112
knotserver/xrpc/merge.go
···
1
-
package xrpc
2
-
3
-
import (
4
-
"encoding/json"
5
-
"errors"
6
-
"fmt"
7
-
"net/http"
8
-
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/patchutil"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
-
"tangled.sh/tangled.sh/core/types"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
-
)
18
-
19
-
func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
20
-
l := x.Logger.With("handler", "Merge")
21
-
fail := func(e xrpcerr.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(xrpcerr.MissingActorDidError)
29
-
return
30
-
}
31
-
32
-
var data tangled.RepoMerge_Input
33
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
34
-
fail(xrpcerr.GenericError(err))
35
-
return
36
-
}
37
-
38
-
did := data.Did
39
-
name := data.Name
40
-
41
-
if did == "" || name == "" {
42
-
fail(xrpcerr.GenericError(fmt.Errorf("did and name are required")))
43
-
return
44
-
}
45
-
46
-
relativeRepoPath, err := securejoin.SecureJoin(did, name)
47
-
if err != nil {
48
-
fail(xrpcerr.GenericError(err))
49
-
return
50
-
}
51
-
52
-
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil {
53
-
l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath)
54
-
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
55
-
return
56
-
}
57
-
58
-
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
59
-
if err != nil {
60
-
fail(xrpcerr.GenericError(err))
61
-
return
62
-
}
63
-
64
-
gr, err := git.Open(repoPath, data.Branch)
65
-
if err != nil {
66
-
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
67
-
return
68
-
}
69
-
70
-
mo := &git.MergeOptions{}
71
-
if data.AuthorName != nil {
72
-
mo.AuthorName = *data.AuthorName
73
-
}
74
-
if data.AuthorEmail != nil {
75
-
mo.AuthorEmail = *data.AuthorEmail
76
-
}
77
-
if data.CommitBody != nil {
78
-
mo.CommitBody = *data.CommitBody
79
-
}
80
-
if data.CommitMessage != nil {
81
-
mo.CommitMessage = *data.CommitMessage
82
-
}
83
-
84
-
mo.FormatPatch = patchutil.IsFormatPatch(data.Patch)
85
-
86
-
err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
87
-
if err != nil {
88
-
var mergeErr *git.ErrMerge
89
-
if errors.As(err, &mergeErr) {
90
-
conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
91
-
for i, conflict := range mergeErr.Conflicts {
92
-
conflicts[i] = types.ConflictInfo{
93
-
Filename: conflict.Filename,
94
-
Reason: conflict.Reason,
95
-
}
96
-
}
97
-
98
-
conflictErr := xrpcerr.NewXrpcError(
99
-
xrpcerr.WithTag("MergeConflict"),
100
-
xrpcerr.WithMessage(fmt.Sprintf("Merge failed due to conflicts: %s", mergeErr.Message)),
101
-
)
102
-
writeError(w, conflictErr, http.StatusConflict)
103
-
return
104
-
} else {
105
-
l.Error("failed to merge", "error", err.Error())
106
-
writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError)
107
-
return
108
-
}
109
-
}
110
-
111
-
w.WriteHeader(http.StatusOK)
112
-
}
-87
knotserver/xrpc/merge_check.go
-87
knotserver/xrpc/merge_check.go
···
1
-
package xrpc
2
-
3
-
import (
4
-
"encoding/json"
5
-
"errors"
6
-
"fmt"
7
-
"net/http"
8
-
9
-
securejoin "github.com/cyphar/filepath-securejoin"
10
-
"tangled.sh/tangled.sh/core/api/tangled"
11
-
"tangled.sh/tangled.sh/core/knotserver/git"
12
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
13
-
)
14
-
15
-
func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) {
16
-
l := x.Logger.With("handler", "MergeCheck")
17
-
fail := func(e xrpcerr.XrpcError) {
18
-
l.Error("failed", "kind", e.Tag, "error", e.Message)
19
-
writeError(w, e, http.StatusBadRequest)
20
-
}
21
-
22
-
var data tangled.RepoMergeCheck_Input
23
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
24
-
fail(xrpcerr.GenericError(err))
25
-
return
26
-
}
27
-
28
-
did := data.Did
29
-
name := data.Name
30
-
31
-
if did == "" || name == "" {
32
-
fail(xrpcerr.GenericError(fmt.Errorf("did and name are required")))
33
-
return
34
-
}
35
-
36
-
relativeRepoPath, err := securejoin.SecureJoin(did, name)
37
-
if err != nil {
38
-
fail(xrpcerr.GenericError(err))
39
-
return
40
-
}
41
-
42
-
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
43
-
if err != nil {
44
-
fail(xrpcerr.GenericError(err))
45
-
return
46
-
}
47
-
48
-
gr, err := git.Open(repoPath, data.Branch)
49
-
if err != nil {
50
-
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
51
-
return
52
-
}
53
-
54
-
err = gr.MergeCheck([]byte(data.Patch), data.Branch)
55
-
56
-
response := tangled.RepoMergeCheck_Output{
57
-
Is_conflicted: false,
58
-
}
59
-
60
-
if err != nil {
61
-
var mergeErr *git.ErrMerge
62
-
if errors.As(err, &mergeErr) {
63
-
response.Is_conflicted = true
64
-
65
-
conflicts := make([]*tangled.RepoMergeCheck_ConflictInfo, len(mergeErr.Conflicts))
66
-
for i, conflict := range mergeErr.Conflicts {
67
-
conflicts[i] = &tangled.RepoMergeCheck_ConflictInfo{
68
-
Filename: conflict.Filename,
69
-
Reason: conflict.Reason,
70
-
}
71
-
}
72
-
response.Conflicts = conflicts
73
-
74
-
if mergeErr.Message != "" {
75
-
response.Message = &mergeErr.Message
76
-
}
77
-
} else {
78
-
response.Is_conflicted = true
79
-
errMsg := err.Error()
80
-
response.Error = &errMsg
81
-
}
82
-
}
83
-
84
-
w.Header().Set("Content-Type", "application/json")
85
-
w.WriteHeader(http.StatusOK)
86
-
json.NewEncoder(w).Encode(response)
87
-
}
+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
+
}
+10
-12
knotserver/xrpc/set_default_branch.go
+10
-12
knotserver/xrpc/set_default_branch.go
···
12
12
"tangled.sh/tangled.sh/core/api/tangled"
13
13
"tangled.sh/tangled.sh/core/knotserver/git"
14
14
"tangled.sh/tangled.sh/core/rbac"
15
-
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
15
)
18
16
19
17
const ActorDid string = "ActorDid"
20
18
21
19
func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
22
20
l := x.Logger
23
-
fail := func(e xrpcerr.XrpcError) {
21
+
fail := func(e XrpcError) {
24
22
l.Error("failed", "kind", e.Tag, "error", e.Message)
25
23
writeError(w, e, http.StatusBadRequest)
26
24
}
27
25
28
26
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
29
27
if !ok {
30
-
fail(xrpcerr.MissingActorDidError)
28
+
fail(MissingActorDidError)
31
29
return
32
30
}
33
31
34
32
var data tangled.RepoSetDefaultBranch_Input
35
33
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
36
-
fail(xrpcerr.GenericError(err))
34
+
fail(GenericError(err))
37
35
return
38
36
}
39
37
40
38
// unfortunately we have to resolve repo-at here
41
39
repoAt, err := syntax.ParseATURI(data.Repo)
42
40
if err != nil {
43
-
fail(xrpcerr.InvalidRepoError(data.Repo))
41
+
fail(InvalidRepoError(data.Repo))
44
42
return
45
43
}
46
44
47
45
// resolve this aturi to extract the repo record
48
46
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
49
47
if err != nil || ident.Handle.IsInvalidHandle() {
50
-
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
48
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
51
49
return
52
50
}
53
51
54
52
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
55
53
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
56
54
if err != nil {
57
-
fail(xrpcerr.GenericError(err))
55
+
fail(GenericError(err))
58
56
return
59
57
}
60
58
61
59
repo := resp.Value.Val.(*tangled.Repo)
62
60
didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name)
63
61
if err != nil {
64
-
fail(xrpcerr.GenericError(err))
62
+
fail(GenericError(err))
65
63
return
66
64
}
67
65
68
66
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
69
67
l.Error("insufficent permissions", "did", actorDid.String())
70
-
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
68
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
71
69
return
72
70
}
73
71
74
72
path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath)
75
73
gr, err := git.PlainOpen(path)
76
74
if err != nil {
77
-
fail(xrpcerr.GenericError(err))
75
+
fail(InvalidRepoError(data.Repo))
78
76
return
79
77
}
80
78
81
79
err = gr.SetDefaultBranch(data.DefaultBranch)
82
80
if err != nil {
83
81
l.Error("setting default branch", "error", err.Error())
84
-
writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError)
82
+
writeError(w, GitError(err), http.StatusInternalServerError)
85
83
return
86
84
}
87
85
-60
knotserver/xrpc/xrpc.go
-60
knotserver/xrpc/xrpc.go
···
1
-
package xrpc
2
-
3
-
import (
4
-
"encoding/json"
5
-
"log/slog"
6
-
"net/http"
7
-
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
"tangled.sh/tangled.sh/core/idresolver"
10
-
"tangled.sh/tangled.sh/core/jetstream"
11
-
"tangled.sh/tangled.sh/core/knotserver/config"
12
-
"tangled.sh/tangled.sh/core/knotserver/db"
13
-
"tangled.sh/tangled.sh/core/notifier"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
17
-
18
-
"github.com/go-chi/chi/v5"
19
-
)
20
-
21
-
type Xrpc struct {
22
-
Config *config.Config
23
-
Db *db.DB
24
-
Ingester *jetstream.JetstreamClient
25
-
Enforcer *rbac.Enforcer
26
-
Logger *slog.Logger
27
-
Notifier *notifier.Notifier
28
-
Resolver *idresolver.Resolver
29
-
ServiceAuth *serviceauth.ServiceAuth
30
-
}
31
-
32
-
func (x *Xrpc) Router() http.Handler {
33
-
r := chi.NewRouter()
34
-
35
-
r.Group(func(r chi.Router) {
36
-
r.Use(x.ServiceAuth.VerifyServiceAuth)
37
-
38
-
r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
39
-
r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo)
40
-
r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo)
41
-
r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
42
-
r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync)
43
-
r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef)
44
-
r.Post("/"+tangled.RepoMergeNSID, x.Merge)
45
-
})
46
-
47
-
// merge check is an open endpoint
48
-
//
49
-
// TODO: should we constrain this more?
50
-
// - we can calculate on PR submit/resubmit/gitRefUpdate etc.
51
-
// - use ETags on clients to keep requests to a minimum
52
-
r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck)
53
-
return r
54
-
}
55
-
56
-
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
57
-
w.Header().Set("Content-Type", "application/json")
58
-
w.WriteHeader(status)
59
-
json.NewEncoder(w).Encode(e)
60
-
}
+1
-8
lexicons/issue/comment.json
+1
-8
lexicons/issue/comment.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": [
13
-
"issue",
14
-
"body",
15
-
"createdAt"
16
-
],
12
+
"required": ["issue", "body", "createdAt"],
17
13
"properties": {
18
14
"issue": {
19
15
"type": "string",
···
22
18
"repo": {
23
19
"type": "string",
24
20
"format": "at-uri"
25
-
},
26
-
"commentId": {
27
-
"type": "integer"
28
21
},
29
22
"owner": {
30
23
"type": "string",
+1
-10
lexicons/issue/issue.json
+1
-10
lexicons/issue/issue.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"issueId",
15
-
"owner",
16
-
"title",
17
-
"createdAt"
18
-
],
12
+
"required": ["repo", "owner", "title", "createdAt"],
19
13
"properties": {
20
14
"repo": {
21
15
"type": "string",
22
16
"format": "at-uri"
23
-
},
24
-
"issueId": {
25
-
"type": "integer"
26
17
},
27
18
"owner": {
28
19
"type": "string",
-24
lexicons/knot/knot.json
-24
lexicons/knot/knot.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.knot",
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
-
}
-33
lexicons/repo/create.json
-33
lexicons/repo/create.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.create",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Create a new repository",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": [
13
-
"rkey"
14
-
],
15
-
"properties": {
16
-
"rkey": {
17
-
"type": "string",
18
-
"description": "Rkey of the repository record"
19
-
},
20
-
"defaultBranch": {
21
-
"type": "string",
22
-
"description": "Default branch to push to"
23
-
},
24
-
"source": {
25
-
"type": "string",
26
-
"description": "A source URL to clone from, populate this when forking or importing a repository."
27
-
}
28
-
}
29
-
}
30
-
}
31
-
}
32
-
}
33
-
}
-32
lexicons/repo/delete.json
-32
lexicons/repo/delete.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.delete",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Delete a repository",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": ["did", "name", "rkey"],
13
-
"properties": {
14
-
"did": {
15
-
"type": "string",
16
-
"format": "did",
17
-
"description": "DID of the repository owner"
18
-
},
19
-
"name": {
20
-
"type": "string",
21
-
"description": "Name of the repository to delete"
22
-
},
23
-
"rkey": {
24
-
"type": "string",
25
-
"description": "Rkey of the repository record"
26
-
}
27
-
}
28
-
}
29
-
}
30
-
}
31
-
}
32
-
}
-53
lexicons/repo/forkStatus.json
-53
lexicons/repo/forkStatus.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.forkStatus",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Check fork status relative to upstream source",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": ["did", "name", "source", "branch", "hiddenRef"],
13
-
"properties": {
14
-
"did": {
15
-
"type": "string",
16
-
"format": "did",
17
-
"description": "DID of the fork owner"
18
-
},
19
-
"name": {
20
-
"type": "string",
21
-
"description": "Name of the forked repository"
22
-
},
23
-
"source": {
24
-
"type": "string",
25
-
"description": "Source repository URL"
26
-
},
27
-
"branch": {
28
-
"type": "string",
29
-
"description": "Branch to check status for"
30
-
},
31
-
"hiddenRef": {
32
-
"type": "string",
33
-
"description": "Hidden ref to use for comparison"
34
-
}
35
-
}
36
-
}
37
-
},
38
-
"output": {
39
-
"encoding": "application/json",
40
-
"schema": {
41
-
"type": "object",
42
-
"required": ["status"],
43
-
"properties": {
44
-
"status": {
45
-
"type": "integer",
46
-
"description": "Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch"
47
-
}
48
-
}
49
-
}
50
-
}
51
-
}
52
-
}
53
-
}
-42
lexicons/repo/forkSync.json
-42
lexicons/repo/forkSync.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.forkSync",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Sync a forked repository with its upstream source",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": [
13
-
"did",
14
-
"source",
15
-
"name",
16
-
"branch"
17
-
],
18
-
"properties": {
19
-
"did": {
20
-
"type": "string",
21
-
"format": "did",
22
-
"description": "DID of the fork owner"
23
-
},
24
-
"source": {
25
-
"type": "string",
26
-
"format": "at-uri",
27
-
"description": "AT-URI of the source repository"
28
-
},
29
-
"name": {
30
-
"type": "string",
31
-
"description": "Name of the forked repository"
32
-
},
33
-
"branch": {
34
-
"type": "string",
35
-
"description": "Branch to sync"
36
-
}
37
-
}
38
-
}
39
-
}
40
-
}
41
-
}
42
-
}
-52
lexicons/repo/merge.json
-52
lexicons/repo/merge.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.merge",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Merge a patch into a repository branch",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": ["did", "name", "patch", "branch"],
13
-
"properties": {
14
-
"did": {
15
-
"type": "string",
16
-
"format": "did",
17
-
"description": "DID of the repository owner"
18
-
},
19
-
"name": {
20
-
"type": "string",
21
-
"description": "Name of the repository"
22
-
},
23
-
"patch": {
24
-
"type": "string",
25
-
"description": "Patch content to merge"
26
-
},
27
-
"branch": {
28
-
"type": "string",
29
-
"description": "Target branch to merge into"
30
-
},
31
-
"authorName": {
32
-
"type": "string",
33
-
"description": "Author name for the merge commit"
34
-
},
35
-
"authorEmail": {
36
-
"type": "string",
37
-
"description": "Author email for the merge commit"
38
-
},
39
-
"commitBody": {
40
-
"type": "string",
41
-
"description": "Additional commit message body"
42
-
},
43
-
"commitMessage": {
44
-
"type": "string",
45
-
"description": "Merge commit message"
46
-
}
47
-
}
48
-
}
49
-
}
50
-
}
51
-
}
52
-
}
-79
lexicons/repo/mergeCheck.json
-79
lexicons/repo/mergeCheck.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.mergeCheck",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Check if a merge is possible between two branches",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": ["did", "name", "patch", "branch"],
13
-
"properties": {
14
-
"did": {
15
-
"type": "string",
16
-
"format": "did",
17
-
"description": "DID of the repository owner"
18
-
},
19
-
"name": {
20
-
"type": "string",
21
-
"description": "Name of the repository"
22
-
},
23
-
"patch": {
24
-
"type": "string",
25
-
"description": "Patch or pull request to check for merge conflicts"
26
-
},
27
-
"branch": {
28
-
"type": "string",
29
-
"description": "Target branch to merge into"
30
-
}
31
-
}
32
-
}
33
-
},
34
-
"output": {
35
-
"encoding": "application/json",
36
-
"schema": {
37
-
"type": "object",
38
-
"required": ["is_conflicted"],
39
-
"properties": {
40
-
"is_conflicted": {
41
-
"type": "boolean",
42
-
"description": "Whether the merge has conflicts"
43
-
},
44
-
"conflicts": {
45
-
"type": "array",
46
-
"description": "List of files with merge conflicts",
47
-
"items": {
48
-
"type": "ref",
49
-
"ref": "#conflictInfo"
50
-
}
51
-
},
52
-
"message": {
53
-
"type": "string",
54
-
"description": "Additional message about the merge check"
55
-
},
56
-
"error": {
57
-
"type": "string",
58
-
"description": "Error message if check failed"
59
-
}
60
-
}
61
-
}
62
-
}
63
-
},
64
-
"conflictInfo": {
65
-
"type": "object",
66
-
"required": ["filename", "reason"],
67
-
"properties": {
68
-
"filename": {
69
-
"type": "string",
70
-
"description": "Name of the conflicted file"
71
-
},
72
-
"reason": {
73
-
"type": "string",
74
-
"description": "Reason for the conflict"
75
-
}
76
-
}
77
-
}
78
-
}
79
-
}
+5
-5
nix/modules/knot.nix
+5
-5
nix/modules/knot.nix
···
93
93
description = "Internal address for inter-service communication";
94
94
};
95
95
96
-
owner = mkOption {
97
-
type = types.str;
98
-
example = "did:plc:qfpnj4og54vl56wngdriaxug";
99
-
description = "DID of owner (required)";
96
+
secretFile = mkOption {
97
+
type = lib.types.path;
98
+
example = "KNOT_SERVER_SECRET=<hash>";
99
+
description = "File containing secret key provided by appview (required)";
100
100
};
101
101
102
102
dbPath = mkOption {
···
199
199
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
200
200
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
201
201
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
202
-
"KNOT_SERVER_OWNER=${cfg.server.owner}"
203
202
];
203
+
EnvironmentFile = cfg.server.secretFile;
204
204
ExecStart = "${cfg.package}/bin/knot server";
205
205
Restart = "always";
206
206
};
+1
-2
nix/vm.nix
+1
-2
nix/vm.nix
···
70
70
};
71
71
# This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall
72
72
networking.firewall.enable = false;
73
-
time.timeZone = "Europe/London";
74
73
services.getty.autologinUser = "root";
75
74
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
76
75
services.tangled-knot = {
77
76
enable = true;
78
77
motd = "Welcome to the development knot!\n";
79
78
server = {
80
-
owner = envVar "TANGLED_VM_KNOT_OWNER";
79
+
secretFile = builtins.toFile "knot-secret" ("KNOT_SERVER_SECRET=" + (envVar "TANGLED_VM_KNOT_SECRET"));
81
80
hostname = "localhost:6000";
82
81
listenAddr = "0.0.0.0:6000";
83
82
};
-13
rbac/rbac.go
-13
rbac/rbac.go
···
100
100
return err
101
101
}
102
102
103
-
func (e *Enforcer) RemoveKnot(knot string) error {
104
-
_, err := e.E.DeleteDomains(knot)
105
-
return err
106
-
}
107
-
108
103
func (e *Enforcer) GetKnotsForUser(did string) ([]string, error) {
109
104
keepFunc := isNotSpindle
110
105
stripFunc := unSpindle
···
275
270
276
271
func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) {
277
272
return e.isInviteAllowed(user, intoSpindle(domain))
278
-
}
279
-
280
-
func (e *Enforcer) IsRepoCreateAllowed(user, domain string) (bool, error) {
281
-
return e.E.Enforce(user, domain, domain, "repo:create")
282
-
}
283
-
284
-
func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) {
285
-
return e.E.Enforce(user, domain, repo, "repo:delete")
286
273
}
287
274
288
275
func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
-3
spindle/engines/nixery/engine.go
-3
spindle/engines/nixery/engine.go
···
201
201
Tty: false,
202
202
Hostname: "spindle",
203
203
WorkingDir: workspaceDir,
204
-
Labels: map[string]string{
205
-
"sh.tangled.pipeline/workflow_id": wid.String(),
206
-
},
207
204
// TODO(winter): investigate whether environment variables passed here
208
205
// get propagated to ContainerExec processes
209
206
}, &container.HostConfig{
+7
-11
spindle/server.go
+7
-11
spindle/server.go
···
25
25
"tangled.sh/tangled.sh/core/spindle/queue"
26
26
"tangled.sh/tangled.sh/core/spindle/secrets"
27
27
"tangled.sh/tangled.sh/core/spindle/xrpc"
28
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
29
28
)
30
29
31
30
//go:embed motd
···
214
213
func (s *Spindle) XrpcRouter() http.Handler {
215
214
logger := s.l.With("route", "xrpc")
216
215
217
-
serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String())
218
-
219
216
x := xrpc.Xrpc{
220
-
Logger: logger,
221
-
Db: s.db,
222
-
Enforcer: s.e,
223
-
Engines: s.engs,
224
-
Config: s.cfg,
225
-
Resolver: s.res,
226
-
Vault: s.vault,
227
-
ServiceAuth: serviceAuth,
217
+
Logger: logger,
218
+
Db: s.db,
219
+
Enforcer: s.e,
220
+
Engines: s.engs,
221
+
Config: s.cfg,
222
+
Resolver: s.res,
223
+
Vault: s.vault,
228
224
}
229
225
230
226
return x.Router()
+10
-11
spindle/xrpc/add_secret.go
+10
-11
spindle/xrpc/add_secret.go
···
13
13
"tangled.sh/tangled.sh/core/api/tangled"
14
14
"tangled.sh/tangled.sh/core/rbac"
15
15
"tangled.sh/tangled.sh/core/spindle/secrets"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
16
)
18
17
19
18
func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
20
19
l := x.Logger
21
-
fail := func(e xrpcerr.XrpcError) {
20
+
fail := func(e XrpcError) {
22
21
l.Error("failed", "kind", e.Tag, "error", e.Message)
23
22
writeError(w, e, http.StatusBadRequest)
24
23
}
25
24
26
25
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
27
26
if !ok {
28
-
fail(xrpcerr.MissingActorDidError)
27
+
fail(MissingActorDidError)
29
28
return
30
29
}
31
30
32
31
var data tangled.RepoAddSecret_Input
33
32
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
34
-
fail(xrpcerr.GenericError(err))
33
+
fail(GenericError(err))
35
34
return
36
35
}
37
36
38
37
if err := secrets.ValidateKey(data.Key); err != nil {
39
-
fail(xrpcerr.GenericError(err))
38
+
fail(GenericError(err))
40
39
return
41
40
}
42
41
43
42
// unfortunately we have to resolve repo-at here
44
43
repoAt, err := syntax.ParseATURI(data.Repo)
45
44
if err != nil {
46
-
fail(xrpcerr.InvalidRepoError(data.Repo))
45
+
fail(InvalidRepoError(data.Repo))
47
46
return
48
47
}
49
48
50
49
// resolve this aturi to extract the repo record
51
50
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
52
51
if err != nil || ident.Handle.IsInvalidHandle() {
53
-
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
52
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
54
53
return
55
54
}
56
55
57
56
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
58
57
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
59
58
if err != nil {
60
-
fail(xrpcerr.GenericError(err))
59
+
fail(GenericError(err))
61
60
return
62
61
}
63
62
64
63
repo := resp.Value.Val.(*tangled.Repo)
65
64
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
66
65
if err != nil {
67
-
fail(xrpcerr.GenericError(err))
66
+
fail(GenericError(err))
68
67
return
69
68
}
70
69
71
70
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
72
71
l.Error("insufficent permissions", "did", actorDid.String())
73
-
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
72
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
74
73
return
75
74
}
76
75
···
84
83
err = x.Vault.AddSecret(r.Context(), secret)
85
84
if err != nil {
86
85
l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err)
87
-
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
86
+
writeError(w, GenericError(err), http.StatusInternalServerError)
88
87
return
89
88
}
90
89
+9
-10
spindle/xrpc/list_secrets.go
+9
-10
spindle/xrpc/list_secrets.go
···
13
13
"tangled.sh/tangled.sh/core/api/tangled"
14
14
"tangled.sh/tangled.sh/core/rbac"
15
15
"tangled.sh/tangled.sh/core/spindle/secrets"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
16
)
18
17
19
18
func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
20
19
l := x.Logger
21
-
fail := func(e xrpcerr.XrpcError) {
20
+
fail := func(e XrpcError) {
22
21
l.Error("failed", "kind", e.Tag, "error", e.Message)
23
22
writeError(w, e, http.StatusBadRequest)
24
23
}
25
24
26
25
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
27
26
if !ok {
28
-
fail(xrpcerr.MissingActorDidError)
27
+
fail(MissingActorDidError)
29
28
return
30
29
}
31
30
32
31
repoParam := r.URL.Query().Get("repo")
33
32
if repoParam == "" {
34
-
fail(xrpcerr.GenericError(fmt.Errorf("empty params")))
33
+
fail(GenericError(fmt.Errorf("empty params")))
35
34
return
36
35
}
37
36
38
37
// unfortunately we have to resolve repo-at here
39
38
repoAt, err := syntax.ParseATURI(repoParam)
40
39
if err != nil {
41
-
fail(xrpcerr.InvalidRepoError(repoParam))
40
+
fail(InvalidRepoError(repoParam))
42
41
return
43
42
}
44
43
45
44
// resolve this aturi to extract the repo record
46
45
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
47
46
if err != nil || ident.Handle.IsInvalidHandle() {
48
-
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
47
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
49
48
return
50
49
}
51
50
52
51
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
53
52
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
54
53
if err != nil {
55
-
fail(xrpcerr.GenericError(err))
54
+
fail(GenericError(err))
56
55
return
57
56
}
58
57
59
58
repo := resp.Value.Val.(*tangled.Repo)
60
59
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
61
60
if err != nil {
62
-
fail(xrpcerr.GenericError(err))
61
+
fail(GenericError(err))
63
62
return
64
63
}
65
64
66
65
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
67
66
l.Error("insufficent permissions", "did", actorDid.String())
68
-
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
67
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
69
68
return
70
69
}
71
70
72
71
ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath))
73
72
if err != nil {
74
73
l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err)
75
-
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
74
+
writeError(w, GenericError(err), http.StatusInternalServerError)
76
75
return
77
76
}
78
77
+9
-10
spindle/xrpc/remove_secret.go
+9
-10
spindle/xrpc/remove_secret.go
···
12
12
"tangled.sh/tangled.sh/core/api/tangled"
13
13
"tangled.sh/tangled.sh/core/rbac"
14
14
"tangled.sh/tangled.sh/core/spindle/secrets"
15
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
15
)
17
16
18
17
func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
19
18
l := x.Logger
20
-
fail := func(e xrpcerr.XrpcError) {
19
+
fail := func(e XrpcError) {
21
20
l.Error("failed", "kind", e.Tag, "error", e.Message)
22
21
writeError(w, e, http.StatusBadRequest)
23
22
}
24
23
25
24
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
26
25
if !ok {
27
-
fail(xrpcerr.MissingActorDidError)
26
+
fail(MissingActorDidError)
28
27
return
29
28
}
30
29
31
30
var data tangled.RepoRemoveSecret_Input
32
31
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
33
-
fail(xrpcerr.GenericError(err))
32
+
fail(GenericError(err))
34
33
return
35
34
}
36
35
37
36
// unfortunately we have to resolve repo-at here
38
37
repoAt, err := syntax.ParseATURI(data.Repo)
39
38
if err != nil {
40
-
fail(xrpcerr.InvalidRepoError(data.Repo))
39
+
fail(InvalidRepoError(data.Repo))
41
40
return
42
41
}
43
42
44
43
// resolve this aturi to extract the repo record
45
44
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
46
45
if err != nil || ident.Handle.IsInvalidHandle() {
47
-
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
46
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
48
47
return
49
48
}
50
49
51
50
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
52
51
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
53
52
if err != nil {
54
-
fail(xrpcerr.GenericError(err))
53
+
fail(GenericError(err))
55
54
return
56
55
}
57
56
58
57
repo := resp.Value.Val.(*tangled.Repo)
59
58
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
60
59
if err != nil {
61
-
fail(xrpcerr.GenericError(err))
60
+
fail(GenericError(err))
62
61
return
63
62
}
64
63
65
64
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
66
65
l.Error("insufficent permissions", "did", actorDid.String())
67
-
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
66
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
68
67
return
69
68
}
70
69
···
75
74
err = x.Vault.RemoveSecret(r.Context(), secret)
76
75
if err != nil {
77
76
l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err)
78
-
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
77
+
writeError(w, GenericError(err), http.StatusInternalServerError)
79
78
return
80
79
}
81
80
+109
-14
spindle/xrpc/xrpc.go
+109
-14
spindle/xrpc/xrpc.go
···
1
1
package xrpc
2
2
3
3
import (
4
+
"context"
4
5
_ "embed"
5
6
"encoding/json"
7
+
"fmt"
6
8
"log/slog"
7
9
"net/http"
10
+
"strings"
8
11
12
+
"github.com/bluesky-social/indigo/atproto/auth"
9
13
"github.com/go-chi/chi/v5"
10
14
11
15
"tangled.sh/tangled.sh/core/api/tangled"
···
15
19
"tangled.sh/tangled.sh/core/spindle/db"
16
20
"tangled.sh/tangled.sh/core/spindle/models"
17
21
"tangled.sh/tangled.sh/core/spindle/secrets"
18
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
19
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
20
22
)
21
23
22
24
const ActorDid string = "ActorDid"
23
25
24
26
type Xrpc struct {
25
-
Logger *slog.Logger
26
-
Db *db.DB
27
-
Enforcer *rbac.Enforcer
28
-
Engines map[string]models.Engine
29
-
Config *config.Config
30
-
Resolver *idresolver.Resolver
31
-
Vault secrets.Manager
32
-
ServiceAuth *serviceauth.ServiceAuth
27
+
Logger *slog.Logger
28
+
Db *db.DB
29
+
Enforcer *rbac.Enforcer
30
+
Engines map[string]models.Engine
31
+
Config *config.Config
32
+
Resolver *idresolver.Resolver
33
+
Vault secrets.Manager
33
34
}
34
35
35
36
func (x *Xrpc) Router() http.Handler {
36
37
r := chi.NewRouter()
37
38
38
-
r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
39
-
r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
40
-
r.With(x.ServiceAuth.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
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)
41
42
42
43
return r
43
44
}
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
+
45
140
// this is slightly different from http_util::write_error to follow the spec:
46
141
//
47
142
// the json object returned must include an "error" and a "message"
48
-
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
143
+
func writeError(w http.ResponseWriter, e XrpcError, status int) {
49
144
w.Header().Set("Content-Type", "application/json")
50
145
w.WriteHeader(status)
51
146
json.NewEncoder(w).Encode(e)
-110
xrpc/errors/errors.go
-110
xrpc/errors/errors.go
···
1
-
package errors
2
-
3
-
import (
4
-
"encoding/json"
5
-
"fmt"
6
-
)
7
-
8
-
type XrpcError struct {
9
-
Tag string `json:"error"`
10
-
Message string `json:"message"`
11
-
}
12
-
13
-
func (x XrpcError) Error() string {
14
-
if x.Message != "" {
15
-
return fmt.Sprintf("%s: %s", x.Tag, x.Message)
16
-
}
17
-
return x.Tag
18
-
}
19
-
20
-
func NewXrpcError(opts ...ErrOpt) XrpcError {
21
-
x := XrpcError{}
22
-
for _, o := range opts {
23
-
o(&x)
24
-
}
25
-
26
-
return x
27
-
}
28
-
29
-
type ErrOpt = func(xerr *XrpcError)
30
-
31
-
func WithTag(tag string) ErrOpt {
32
-
return func(xerr *XrpcError) {
33
-
xerr.Tag = tag
34
-
}
35
-
}
36
-
37
-
func WithMessage[S ~string](s S) ErrOpt {
38
-
return func(xerr *XrpcError) {
39
-
xerr.Message = string(s)
40
-
}
41
-
}
42
-
43
-
func WithError(e error) ErrOpt {
44
-
return func(xerr *XrpcError) {
45
-
xerr.Message = e.Error()
46
-
}
47
-
}
48
-
49
-
var MissingActorDidError = NewXrpcError(
50
-
WithTag("MissingActorDid"),
51
-
WithMessage("actor DID not supplied"),
52
-
)
53
-
54
-
var AuthError = func(err error) XrpcError {
55
-
return NewXrpcError(
56
-
WithTag("Auth"),
57
-
WithError(fmt.Errorf("signature verification failed: %w", err)),
58
-
)
59
-
}
60
-
61
-
var InvalidRepoError = func(r string) XrpcError {
62
-
return NewXrpcError(
63
-
WithTag("InvalidRepo"),
64
-
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
65
-
)
66
-
}
67
-
68
-
var GitError = func(e error) XrpcError {
69
-
return NewXrpcError(
70
-
WithTag("Git"),
71
-
WithError(fmt.Errorf("git error: %w", e)),
72
-
)
73
-
}
74
-
75
-
var AccessControlError = func(d string) XrpcError {
76
-
return NewXrpcError(
77
-
WithTag("AccessControl"),
78
-
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
79
-
)
80
-
}
81
-
82
-
var RepoExistsError = func(r string) XrpcError {
83
-
return NewXrpcError(
84
-
WithTag("RepoExists"),
85
-
WithError(fmt.Errorf("repo already exists: %s", r)),
86
-
)
87
-
}
88
-
89
-
var RecordExistsError = func(r string) XrpcError {
90
-
return NewXrpcError(
91
-
WithTag("RecordExists"),
92
-
WithError(fmt.Errorf("repo already exists: %s", r)),
93
-
)
94
-
}
95
-
96
-
func GenericError(err error) XrpcError {
97
-
return NewXrpcError(
98
-
WithTag("Generic"),
99
-
WithError(err),
100
-
)
101
-
}
102
-
103
-
func Unmarshal(errStr string) (XrpcError, error) {
104
-
var xerr XrpcError
105
-
err := json.Unmarshal([]byte(errStr), &xerr)
106
-
if err != nil {
107
-
return XrpcError{}, fmt.Errorf("failed to unmarshal XrpcError: %w", err)
108
-
}
109
-
return xerr, nil
110
-
}
-65
xrpc/serviceauth/service_auth.go
-65
xrpc/serviceauth/service_auth.go
···
1
-
package serviceauth
2
-
3
-
import (
4
-
"context"
5
-
"encoding/json"
6
-
"log/slog"
7
-
"net/http"
8
-
"strings"
9
-
10
-
"github.com/bluesky-social/indigo/atproto/auth"
11
-
"tangled.sh/tangled.sh/core/idresolver"
12
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
13
-
)
14
-
15
-
const ActorDid string = "ActorDid"
16
-
17
-
type ServiceAuth struct {
18
-
logger *slog.Logger
19
-
resolver *idresolver.Resolver
20
-
audienceDid string
21
-
}
22
-
23
-
func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth {
24
-
return &ServiceAuth{
25
-
logger: logger,
26
-
resolver: resolver,
27
-
audienceDid: audienceDid,
28
-
}
29
-
}
30
-
31
-
func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler {
32
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33
-
l := sa.logger.With("url", r.URL)
34
-
35
-
token := r.Header.Get("Authorization")
36
-
token = strings.TrimPrefix(token, "Bearer ")
37
-
38
-
s := auth.ServiceAuthValidator{
39
-
Audience: sa.audienceDid,
40
-
Dir: sa.resolver.Directory(),
41
-
}
42
-
43
-
did, err := s.Validate(r.Context(), token, nil)
44
-
if err != nil {
45
-
l.Error("signature verification failed", "err", err)
46
-
writeError(w, xrpcerr.AuthError(err), http.StatusForbidden)
47
-
return
48
-
}
49
-
50
-
r = r.WithContext(
51
-
context.WithValue(r.Context(), ActorDid, did),
52
-
)
53
-
54
-
next.ServeHTTP(w, r)
55
-
})
56
-
}
57
-
58
-
// this is slightly different from http_util::write_error to follow the spec:
59
-
//
60
-
// the json object returned must include an "error" and a "message"
61
-
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
62
-
w.Header().Set("Content-Type", "application/json")
63
-
w.WriteHeader(status)
64
-
json.NewEncoder(w).Encode(e)
65
-
}