+130
api/tangled/cbor_gen.go
+130
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
+
}
2144
2274
func (t *KnotMember) MarshalCBOR(w io.Writer) error {
2145
2275
if t == nil {
2146
2276
_, err := w.Write(cbg.CborNull)
+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
+
}
+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
+
615
640
// recreate and add rkey + created columns with default constraint
616
641
runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error {
617
642
// create new table
+89
-125
appview/db/registration.go
+89
-125
appview/db/registration.go
···
1
1
package db
2
2
3
3
import (
4
-
"crypto/rand"
5
4
"database/sql"
6
-
"encoding/hex"
7
5
"fmt"
8
-
"log"
6
+
"strings"
9
7
"time"
10
8
)
11
9
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
18
19
}
19
20
20
21
func (r *Registration) Status() Status {
21
-
if r.Registered != nil {
22
+
if r.ReadOnly {
23
+
return ReadOnly
24
+
} else if r.Registered != nil {
22
25
return Registered
23
26
} else {
24
27
return Pending
25
28
}
26
29
}
27
30
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
+
28
43
type Status uint32
29
44
30
45
const (
31
46
Registered Status = iota
32
47
Pending
48
+
ReadOnly
33
49
)
34
50
35
-
// returns registered status, did of owner, error
36
-
func RegistrationsByDid(e Execer, did string) ([]Registration, error) {
51
+
func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) {
37
52
var registrations []Registration
38
53
39
-
rows, err := e.Query(`
40
-
select id, domain, did, created, registered from registrations
41
-
where did = ?
42
-
`, did)
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...)
43
76
if err != nil {
44
77
return nil, err
45
78
}
46
79
47
80
for rows.Next() {
48
-
var createdAt *string
49
-
var registeredAt *string
50
-
var registration Registration
51
-
err = rows.Scan(®istration.Id, ®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
81
+
var createdAt string
82
+
var registeredAt sql.Null[string]
83
+
var readOnly int
84
+
var reg Registration
52
85
86
+
err = rows.Scan(®.Id, ®.Domain, ®.ByDid, &createdAt, ®isteredAt, &readOnly)
53
87
if err != nil {
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
-
}
88
+
return nil, err
89
+
}
62
90
63
-
registration.Created = &createdAtTime
64
-
registration.Registered = registeredAtTime
65
-
registrations = append(registrations, registration)
91
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
92
+
reg.Created = &t
66
93
}
67
-
}
68
94
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
77
-
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)
95
+
if registeredAt.Valid {
96
+
if t, err := time.Parse(time.RFC3339, registeredAt.V); err == nil {
97
+
reg.Registered = &t
98
+
}
99
+
}
82
100
83
-
if err != nil {
84
-
if err == sql.ErrNoRows {
85
-
return nil, nil
86
-
} else {
87
-
return nil, err
101
+
if readOnly != 0 {
102
+
reg.ReadOnly = true
88
103
}
89
-
}
90
104
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
105
+
registrations = append(registrations, reg)
96
106
}
97
107
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)
108
+
return registrations, nil
108
109
}
109
110
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
115
-
}
116
-
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
-
}
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()...)
127
117
}
128
118
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
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 ")
139
122
}
140
123
141
-
return secret, nil
124
+
_, err := e.Exec(query, args...)
125
+
return err
142
126
}
143
127
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
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
154
134
}
155
135
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
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()...)
160
142
}
161
143
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
-
}
172
-
}
173
-
174
-
if err = rows.Err(); err != nil {
175
-
return nil, err
144
+
whereClause := ""
145
+
if conditions != nil {
146
+
whereClause = " where " + strings.Join(conditions, " and ")
176
147
}
177
148
178
-
return domains, nil
179
-
}
149
+
query := fmt.Sprintf(`delete from registrations %s`, whereClause)
180
150
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)
187
-
151
+
_, err := e.Exec(query, args...)
188
152
return err
189
153
}
+163
-3
appview/ingester.go
+163
-3
appview/ingester.go
···
14
14
"tangled.sh/tangled.sh/core/api/tangled"
15
15
"tangled.sh/tangled.sh/core/appview/config"
16
16
"tangled.sh/tangled.sh/core/appview/db"
17
-
"tangled.sh/tangled.sh/core/appview/spindleverify"
17
+
"tangled.sh/tangled.sh/core/appview/serververify"
18
18
"tangled.sh/tangled.sh/core/idresolver"
19
19
"tangled.sh/tangled.sh/core/rbac"
20
20
)
···
64
64
err = i.ingestSpindleMember(e)
65
65
case tangled.SpindleNSID:
66
66
err = i.ingestSpindle(e)
67
+
case tangled.KnotMemberNSID:
68
+
err = i.ingestKnotMember(e)
69
+
case tangled.KnotNSID:
70
+
err = i.ingestKnot(e)
67
71
case tangled.StringNSID:
68
72
err = i.ingestString(e)
69
73
}
···
475
479
return err
476
480
}
477
481
478
-
err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
482
+
err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
479
483
if err != nil {
480
484
l.Error("failed to add spindle to db", "err", err, "instance", instance)
481
485
return err
482
486
}
483
487
484
-
_, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did)
488
+
_, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did)
485
489
if err != nil {
486
490
return fmt.Errorf("failed to mark verified: %w", err)
487
491
}
···
609
613
610
614
return nil
611
615
}
616
+
617
+
func (i *Ingester) ingestKnotMember(e *models.Event) error {
618
+
did := e.Did
619
+
var err error
620
+
621
+
l := i.Logger.With("handler", "ingestKnotMember")
622
+
l = l.With("nsid", e.Commit.Collection)
623
+
624
+
switch e.Commit.Operation {
625
+
case models.CommitOperationCreate:
626
+
raw := json.RawMessage(e.Commit.Record)
627
+
record := tangled.KnotMember{}
628
+
err = json.Unmarshal(raw, &record)
629
+
if err != nil {
630
+
l.Error("invalid record", "err", err)
631
+
return err
632
+
}
633
+
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)
638
+
}
639
+
640
+
memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject)
641
+
if err != nil {
642
+
return err
643
+
}
644
+
645
+
if memberId.Handle.IsInvalidHandle() {
646
+
return err
647
+
}
648
+
649
+
err = i.Enforcer.AddKnotMember(record.Domain, memberId.DID.String())
650
+
if err != nil {
651
+
return fmt.Errorf("failed to update ACLs: %w", err)
652
+
}
653
+
654
+
l.Info("added knot member")
655
+
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)
664
+
}
665
+
666
+
return nil
667
+
}
668
+
669
+
func (i *Ingester) ingestKnot(e *models.Event) error {
670
+
did := e.Did
671
+
var err error
672
+
673
+
l := i.Logger.With("handler", "ingestKnot")
674
+
l = l.With("nsid", e.Commit.Collection)
675
+
676
+
switch e.Commit.Operation {
677
+
case models.CommitOperationCreate:
678
+
raw := json.RawMessage(e.Commit.Record)
679
+
record := tangled.Knot{}
680
+
err = json.Unmarshal(raw, &record)
681
+
if err != nil {
682
+
l.Error("invalid record", "err", err)
683
+
return err
684
+
}
685
+
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)
694
+
if err != nil {
695
+
l.Error("failed to add knot to db", "err", err, "domain", domain)
696
+
return err
697
+
}
698
+
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
703
+
}
704
+
705
+
err = serververify.MarkKnotVerified(ddb, i.Enforcer, domain, did)
706
+
if err != nil {
707
+
return fmt.Errorf("failed to mark verified: %w", err)
708
+
}
709
+
710
+
return nil
711
+
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
+
)
726
+
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))
731
+
}
732
+
registration := registrations[0]
733
+
734
+
tx, err := ddb.Begin()
735
+
if err != nil {
736
+
return err
737
+
}
738
+
defer func() {
739
+
tx.Rollback()
740
+
i.Enforcer.E.LoadPolicy()
741
+
}()
742
+
743
+
err = db.DeleteKnot(
744
+
tx,
745
+
db.FilterEq("did", did),
746
+
db.FilterEq("domain", domain),
747
+
)
748
+
if err != nil {
749
+
return err
750
+
}
751
+
752
+
if registration.Registered != nil {
753
+
err = i.Enforcer.RemoveKnot(domain)
754
+
if err != nil {
755
+
return err
756
+
}
757
+
}
758
+
759
+
err = tx.Commit()
760
+
if err != nil {
761
+
return err
762
+
}
763
+
764
+
err = i.Enforcer.E.SavePolicy()
765
+
if err != nil {
766
+
return err
767
+
}
768
+
}
769
+
770
+
return nil
771
+
}
+444
-217
appview/knots/knots.go
+444
-217
appview/knots/knots.go
···
1
1
package knots
2
2
3
3
import (
4
-
"context"
5
-
"crypto/hmac"
6
-
"crypto/sha256"
7
-
"encoding/hex"
4
+
"errors"
8
5
"fmt"
6
+
"log"
9
7
"log/slog"
10
8
"net/http"
11
-
"strings"
9
+
"slices"
12
10
"time"
13
11
14
12
"github.com/go-chi/chi/v5"
···
18
16
"tangled.sh/tangled.sh/core/appview/middleware"
19
17
"tangled.sh/tangled.sh/core/appview/oauth"
20
18
"tangled.sh/tangled.sh/core/appview/pages"
19
+
"tangled.sh/tangled.sh/core/appview/serververify"
21
20
"tangled.sh/tangled.sh/core/eventconsumer"
22
21
"tangled.sh/tangled.sh/core/idresolver"
23
-
"tangled.sh/tangled.sh/core/knotclient"
24
22
"tangled.sh/tangled.sh/core/rbac"
25
23
"tangled.sh/tangled.sh/core/tid"
26
24
···
39
37
Knotstream *eventconsumer.Consumer
40
38
}
41
39
42
-
func (k *Knots) Router(mw *middleware.Middleware) http.Handler {
40
+
func (k *Knots) Router() http.Handler {
43
41
r := chi.NewRouter()
44
42
45
-
r.Use(middleware.AuthMiddleware(k.OAuth))
43
+
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots)
44
+
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register)
46
45
47
-
r.Get("/", k.index)
48
-
r.Post("/key", k.generateKey)
46
+
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard)
47
+
r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete)
49
48
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
-
})
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)
52
+
53
+
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner)
60
54
61
55
return r
62
56
}
63
57
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
-
58
+
func (k *Knots) knots(w http.ResponseWriter, r *http.Request) {
68
59
user := k.OAuth.GetUser(r)
69
-
registrations, err := db.RegistrationsByDid(k.Db, user.Did)
60
+
registrations, err := db.GetRegistrations(
61
+
k.Db,
62
+
db.FilterEq("did", user.Did),
63
+
)
70
64
if err != nil {
71
-
l.Error("failed to get registrations by did", "err", err)
65
+
k.Logger.Error("failed to fetch knot registrations", "err", err)
66
+
w.WriteHeader(http.StatusInternalServerError)
67
+
return
72
68
}
73
69
74
70
k.Pages.Knots(w, pages.KnotsParams{
···
77
73
})
78
74
}
79
75
80
-
// requires auth
81
-
func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) {
82
-
l := k.Logger.With("handler", "generateKey")
76
+
func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
77
+
l := k.Logger.With("handler", "dashboard")
83
78
84
79
user := k.OAuth.GetUser(r)
85
-
did := user.Did
86
-
l = l.With("did", did)
80
+
l = l.With("user", user.Did)
87
81
88
-
// check if domain is valid url, and strip extra bits down to just host
89
-
domain := r.FormValue("domain")
82
+
domain := chi.URLParam(r, "domain")
90
83
if domain == "" {
91
-
l.Error("empty domain")
92
-
http.Error(w, "Invalid form", http.StatusBadRequest)
93
84
return
94
85
}
95
86
l = l.With("domain", domain)
96
87
97
-
noticeId := "registration-error"
98
-
fail := func() {
99
-
k.Pages.Notice(w, noticeId, "Failed to generate registration key.")
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
100
97
}
98
+
if len(registrations) != 1 {
99
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
100
+
return
101
+
}
102
+
registration := registrations[0]
101
103
102
-
key, err := db.GenerateRegistrationKey(k.Db, domain, did)
104
+
members, err := k.Enforcer.GetUserByRole("server:member", domain)
103
105
if err != nil {
104
-
l.Error("failed to generate registration key", "err", err)
105
-
fail()
106
+
l.Error("failed to get knot members", "err", err)
107
+
http.Error(w, "Not found", http.StatusInternalServerError)
106
108
return
107
109
}
110
+
slices.Sort(members)
108
111
109
-
allRegs, err := db.RegistrationsByDid(k.Db, did)
112
+
repos, err := db.GetRepos(
113
+
k.Db,
114
+
0,
115
+
db.FilterEq("knot", domain),
116
+
)
110
117
if err != nil {
111
-
l.Error("failed to generate registration key", "err", err)
112
-
fail()
118
+
l.Error("failed to get knot repos", "err", err)
119
+
http.Error(w, "Not found", http.StatusInternalServerError)
113
120
return
114
121
}
115
122
116
-
k.Pages.KnotListingFull(w, pages.KnotListingFullParams{
117
-
Registrations: allRegs,
118
-
})
119
-
k.Pages.KnotSecret(w, pages.KnotSecretParams{
120
-
Secret: key,
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,
121
135
})
122
136
}
123
137
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")
138
+
func (k *Knots) register(w http.ResponseWriter, r *http.Request) {
127
139
user := k.OAuth.GetUser(r)
140
+
l := k.Logger.With("handler", "register")
128
141
129
-
noticeId := "operation-error"
130
-
defaultErr := "Failed to initialize knot. Try again later."
142
+
noticeId := "register-error"
143
+
defaultErr := "Failed to register knot. Try again later."
131
144
fail := func() {
132
145
k.Pages.Notice(w, noticeId, defaultErr)
133
146
}
134
147
135
-
domain := chi.URLParam(r, "domain")
148
+
domain := r.FormValue("domain")
136
149
if domain == "" {
137
-
http.Error(w, "malformed url", http.StatusBadRequest)
150
+
k.Pages.Notice(w, noticeId, "Incomplete form.")
138
151
return
139
152
}
140
153
l = l.With("domain", domain)
154
+
l = l.With("user", user.Did)
141
155
142
-
l.Info("checking domain")
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
+
}()
143
166
144
-
registration, err := db.RegistrationByDomain(k.Db, domain)
167
+
err = db.AddKnot(tx, domain, user.Did)
145
168
if err != nil {
146
-
l.Error("failed to get registration for domain", "err", err)
169
+
l.Error("failed to insert", "err", err)
147
170
fail()
148
171
return
149
172
}
150
-
if registration.ByDid != user.Did {
151
-
l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did)
152
-
w.WriteHeader(http.StatusUnauthorized)
173
+
174
+
err = k.Enforcer.AddKnot(domain)
175
+
if err != nil {
176
+
l.Error("failed to create knot", "err", err)
177
+
fail()
153
178
return
154
179
}
155
180
156
-
secret, err := db.GetRegistrationKey(k.Db, domain)
181
+
// create record on pds
182
+
client, err := k.OAuth.AuthorizedClient(r)
157
183
if err != nil {
158
-
l.Error("failed to get registration key for domain", "err", err)
184
+
l.Error("failed to authorize client", "err", err)
159
185
fail()
160
186
return
161
187
}
162
188
163
-
client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
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
+
164
208
if err != nil {
165
-
l.Error("failed to create knotclient", "err", err)
209
+
l.Error("failed to put record", "err", err)
166
210
fail()
167
211
return
168
212
}
169
213
170
-
resp, err := client.Init(user.Did)
214
+
err = tx.Commit()
171
215
if err != nil {
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)
216
+
l.Error("failed to commit transaction", "err", err)
217
+
fail()
174
218
return
175
219
}
176
220
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)
221
+
err = k.Enforcer.E.SavePolicy()
222
+
if err != nil {
223
+
l.Error("failed to update ACL", "err", err)
224
+
k.Pages.HxRefresh(w)
180
225
return
181
226
}
182
227
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)
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)
186
233
return
187
234
}
188
235
189
-
// verify response mac
190
-
signature := resp.Header.Get("X-Signature")
191
-
signatureBytes, err := hex.DecodeString(signature)
236
+
err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did)
192
237
if err != nil {
238
+
l.Error("failed to mark verified", "err", err)
239
+
k.Pages.HxRefresh(w)
193
240
return
194
241
}
195
242
196
-
expectedMac := hmac.New(sha256.New, []byte(secret))
197
-
expectedMac.Write([]byte("ok"))
243
+
// add this knot to knotstream
244
+
go k.Knotstream.AddSource(
245
+
r.Context(),
246
+
eventconsumer.NewKnotSource(domain),
247
+
)
248
+
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)
261
+
}
262
+
263
+
domain := chi.URLParam(r, "domain")
264
+
if domain == "" {
265
+
l.Error("empty domain")
266
+
fail()
267
+
return
268
+
}
198
269
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)
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
+
)
276
+
if err != nil {
277
+
l.Error("failed to get registration", "err", err)
278
+
fail()
202
279
return
203
280
}
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]
204
287
205
-
tx, err := k.Db.BeginTx(r.Context(), nil)
288
+
tx, err := k.Db.Begin()
206
289
if err != nil {
207
-
l.Error("failed to start tx", "err", err)
290
+
l.Error("failed to start txn", "err", err)
208
291
fail()
209
292
return
210
293
}
211
294
defer func() {
212
295
tx.Rollback()
213
-
err = k.Enforcer.E.LoadPolicy()
214
-
if err != nil {
215
-
l.Error("rollback failed", "err", err)
216
-
}
296
+
k.Enforcer.E.LoadPolicy()
217
297
}()
218
298
219
-
// mark as registered
220
-
err = db.Register(tx, domain)
299
+
err = db.DeleteKnot(
300
+
tx,
301
+
db.FilterEq("did", user.Did),
302
+
db.FilterEq("domain", domain),
303
+
)
221
304
if err != nil {
222
-
l.Error("failed to register domain", "err", err)
305
+
l.Error("failed to delete registration", "err", err)
223
306
fail()
224
307
return
225
308
}
226
309
227
-
// set permissions for this did as owner
228
-
reg, err := db.RegistrationByDomain(tx, domain)
229
-
if err != nil {
230
-
l.Error("failed get registration by domain", "err", err)
231
-
fail()
232
-
return
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
+
}
233
318
}
234
319
235
-
// add basic acls for this domain
236
-
err = k.Enforcer.AddKnot(domain)
320
+
client, err := k.OAuth.AuthorizedClient(r)
237
321
if err != nil {
238
-
l.Error("failed to add knot to enforcer", "err", err)
322
+
l.Error("failed to authorize client", "err", err)
239
323
fail()
240
324
return
241
325
}
242
326
243
-
// add this did as owner of this domain
244
-
err = k.Enforcer.AddKnotOwner(domain, reg.ByDid)
327
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
328
+
Collection: tangled.KnotNSID,
329
+
Repo: user.Did,
330
+
Rkey: domain,
331
+
})
245
332
if err != nil {
246
-
l.Error("failed to add knot owner to enforcer", "err", err)
247
-
fail()
248
-
return
333
+
// non-fatal
334
+
l.Error("failed to delete record", "err", err)
249
335
}
250
336
251
337
err = tx.Commit()
252
338
if err != nil {
253
-
l.Error("failed to commit changes", "err", err)
339
+
l.Error("failed to delete knot", "err", err)
254
340
fail()
255
341
return
256
342
}
257
343
258
344
err = k.Enforcer.E.SavePolicy()
259
345
if err != nil {
260
-
l.Error("failed to update ACLs", "err", err)
261
-
fail()
346
+
l.Error("failed to update ACL", "err", err)
347
+
k.Pages.HxRefresh(w)
262
348
return
263
349
}
264
350
265
-
// add this knot to knotstream
266
-
go k.Knotstream.AddSource(
267
-
context.Background(),
268
-
eventconsumer.NewKnotSource(domain),
269
-
)
351
+
shouldRedirect := r.Header.Get("shouldRedirect")
352
+
if shouldRedirect == "true" {
353
+
k.Pages.HxRedirect(w, "/knots")
354
+
return
355
+
}
270
356
271
-
k.Pages.KnotListing(w, pages.KnotListingParams{
272
-
Registration: *reg,
273
-
})
357
+
w.Write([]byte{})
274
358
}
275
359
276
-
func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
277
-
l := k.Logger.With("handler", "dashboard")
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."
278
366
fail := func() {
279
-
w.WriteHeader(http.StatusInternalServerError)
367
+
k.Pages.Notice(w, noticeId, defaultErr)
280
368
}
281
369
282
370
domain := chi.URLParam(r, "domain")
283
371
if domain == "" {
284
-
http.Error(w, "malformed url", http.StatusBadRequest)
372
+
l.Error("empty domain")
373
+
fail()
285
374
return
286
375
}
287
376
l = l.With("domain", domain)
377
+
l = l.With("user", user.Did)
288
378
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)
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
+
)
294
385
if err != nil {
295
-
l.Error("failed to query enforcer", "err", err)
386
+
l.Error("failed to get registration", "err", err)
296
387
fail()
388
+
return
297
389
}
298
-
if !ok {
299
-
http.Error(w, "only owners can view dashboards", http.StatusUnauthorized)
390
+
if len(registrations) != 1 {
391
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
392
+
fail()
300
393
return
301
394
}
395
+
registration := registrations[0]
302
396
303
-
reg, err := db.RegistrationByDomain(k.Db, domain)
397
+
// begin verification
398
+
err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev)
304
399
if err != nil {
305
-
l.Error("failed to get registration by domain", "err", err)
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
+
306
412
fail()
307
413
return
308
414
}
309
415
310
-
var members []string
311
-
if reg.Registered != nil {
312
-
members, err = k.Enforcer.GetUserByRole("server:member", domain)
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)
313
429
if err != nil {
314
-
l.Error("failed to get members list", "err", err)
430
+
l.Error("failed to authorize client", "err", err)
315
431
fail()
316
432
return
317
433
}
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
+
}
318
456
}
319
457
320
-
repos, err := db.GetRepos(
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(
321
472
k.Db,
322
-
0,
323
-
db.FilterEq("knot", domain),
324
-
db.FilterIn("did", members),
473
+
db.FilterEq("did", user.Did),
474
+
db.FilterEq("domain", domain),
325
475
)
326
476
if err != nil {
327
-
l.Error("failed to get repos list", "err", err)
477
+
l.Error("failed to get registration", "err", err)
328
478
fail()
329
479
return
330
480
}
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)
335
-
}
336
-
337
-
k.Pages.Knot(w, pages.KnotParams{
338
-
LoggedInUser: user,
339
-
Registration: reg,
340
-
Members: members,
341
-
Repos: repoByMember,
342
-
IsOwner: true,
343
-
})
344
-
}
345
-
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")
349
-
350
-
domain := chi.URLParam(r, "domain")
351
-
if domain == "" {
352
-
http.Error(w, "malformed url", http.StatusBadRequest)
481
+
if len(registrations) != 1 {
482
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
483
+
fail()
353
484
return
354
485
}
355
-
l = l.With("domain", domain)
486
+
updatedRegistration := registrations[0]
356
487
357
-
// list all members for this domain
358
-
memberDids, err := k.Enforcer.GetUserByRole("server:member", domain)
359
-
if err != nil {
360
-
w.Write([]byte("failed to fetch member list"))
361
-
return
362
-
}
488
+
log.Println(updatedRegistration)
363
489
364
-
w.Write([]byte(strings.Join(memberDids, "\n")))
490
+
w.Header().Set("HX-Reswap", "outerHTML")
491
+
k.Pages.KnotListing(w, pages.KnotListingParams{
492
+
Registration: &updatedRegistration,
493
+
})
365
494
}
366
495
367
-
// add member to domain, requires auth and requires invite access
368
496
func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
369
-
l := k.Logger.With("handler", "members")
497
+
user := k.OAuth.GetUser(r)
498
+
l := k.Logger.With("handler", "addMember")
370
499
371
500
domain := chi.URLParam(r, "domain")
372
501
if domain == "" {
373
-
http.Error(w, "malformed url", http.StatusBadRequest)
502
+
l.Error("empty domain")
503
+
http.Error(w, "Not found", http.StatusNotFound)
374
504
return
375
505
}
376
506
l = l.With("domain", domain)
507
+
l = l.With("user", user.Did)
377
508
378
-
reg, err := db.RegistrationByDomain(k.Db, domain)
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
+
)
379
515
if err != nil {
380
-
l.Error("failed to get registration by domain", "err", err)
381
-
http.Error(w, "malformed url", http.StatusBadRequest)
516
+
l.Error("failed to get registration", "err", err)
517
+
return
518
+
}
519
+
if len(registrations) != 1 {
520
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
382
521
return
383
522
}
523
+
registration := registrations[0]
384
524
385
-
noticeId := fmt.Sprintf("add-member-error-%d", reg.Id)
386
-
l = l.With("notice-id", noticeId)
525
+
noticeId := fmt.Sprintf("add-member-error-%d", registration.Id)
387
526
defaultErr := "Failed to add member. Try again later."
388
527
fail := func() {
389
528
k.Pages.Notice(w, noticeId, defaultErr)
390
529
}
391
530
392
-
subjectIdentifier := r.FormValue("subject")
393
-
if subjectIdentifier == "" {
394
-
http.Error(w, "malformed form", http.StatusBadRequest)
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.")
395
535
return
396
536
}
397
-
l = l.With("subjectIdentifier", subjectIdentifier)
537
+
l = l.With("member", member)
398
538
399
-
subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier)
539
+
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
400
540
if err != nil {
401
-
l.Error("failed to resolve identity", "err", err)
541
+
l.Error("failed to resolve member identity to handle", "err", err)
402
542
k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
403
543
return
404
544
}
405
-
l = l.With("subjectDid", subjectIdentity.DID)
406
-
407
-
l.Info("adding member to knot")
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
+
}
408
550
409
-
// announce this relation into the firehose, store into owners' pds
551
+
// write to pds
410
552
client, err := k.OAuth.AuthorizedClient(r)
411
553
if err != nil {
412
-
l.Error("failed to create client", "err", err)
554
+
l.Error("failed to authorize client", "err", err)
413
555
fail()
414
556
return
415
557
}
416
558
417
-
currentUser := k.OAuth.GetUser(r)
418
-
createdAt := time.Now().Format(time.RFC3339)
419
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
559
+
rkey := tid.TID()
560
+
561
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
420
562
Collection: tangled.KnotMemberNSID,
421
-
Repo: currentUser.Did,
422
-
Rkey: tid.TID(),
563
+
Repo: user.Did,
564
+
Rkey: rkey,
423
565
Record: &lexutil.LexiconTypeDecoder{
424
566
Val: &tangled.KnotMember{
425
-
Subject: subjectIdentity.DID.String(),
567
+
CreatedAt: time.Now().Format(time.RFC3339),
426
568
Domain: domain,
427
-
CreatedAt: createdAt,
428
-
}},
569
+
Subject: memberId.DID.String(),
570
+
},
571
+
},
429
572
})
430
-
// invalid record
573
+
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.")
576
+
return
577
+
}
578
+
579
+
err = k.Enforcer.AddKnotMember(domain, memberId.DID.String())
431
580
if err != nil {
432
-
l.Error("failed to write to PDS", "err", err)
581
+
l.Error("failed to add member to ACLs", "err", err)
433
582
fail()
434
583
return
435
584
}
436
-
l = l.With("at-uri", resp.Uri)
437
-
l.Info("wrote record to PDS")
438
585
439
-
secret, err := db.GetRegistrationKey(k.Db, domain)
586
+
err = k.Enforcer.E.SavePolicy()
440
587
if err != nil {
441
-
l.Error("failed to get registration key", "err", err)
588
+
l.Error("failed to save ACL policy", "err", err)
589
+
fail()
590
+
return
591
+
}
592
+
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")
442
610
fail()
443
611
return
444
612
}
613
+
l = l.With("domain", domain)
614
+
l = l.With("user", user.Did)
445
615
446
-
ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
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
+
)
447
622
if err != nil {
448
-
l.Error("failed to create client", "err", err)
449
-
fail()
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)
628
+
return
629
+
}
630
+
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.")
450
635
return
451
636
}
637
+
l = l.With("member", member)
452
638
453
-
ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
639
+
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
454
640
if err != nil {
455
-
l.Error("failed to reach knotserver", "err", err)
456
-
k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.")
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.")
457
648
return
458
649
}
459
650
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))
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()
463
656
return
464
657
}
465
658
466
-
err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
659
+
client, err := k.OAuth.AuthorizedClient(r)
467
660
if err != nil {
468
-
l.Error("failed to add member to enforcer", "err", err)
661
+
l.Error("failed to authorize client", "err", err)
469
662
fail()
470
663
return
471
664
}
472
665
473
-
// success
474
-
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
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()
672
+
if err != nil {
673
+
l.Error("failed to save ACLs", "err", err)
674
+
fail()
675
+
return
676
+
}
677
+
678
+
// ok
679
+
k.Pages.HxRefresh(w)
475
680
}
476
681
477
-
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
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
+
})
478
705
}
+98
-82
appview/oauth/handler/handler.go
+98
-82
appview/oauth/handler/handler.go
···
8
8
"log"
9
9
"net/http"
10
10
"net/url"
11
+
"slices"
11
12
"strings"
12
13
"time"
13
14
···
25
26
"tangled.sh/tangled.sh/core/appview/oauth/client"
26
27
"tangled.sh/tangled.sh/core/appview/pages"
27
28
"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
+
356
363
func (o *OAuthHandler) addToDefaultSpindle(did string) {
357
364
// use the tangled.sh app password to get an accessJwt
358
365
// and create an sh.tangled.spindle.member record with that
359
-
360
-
defaultSpindle := "spindle.tangled.sh"
361
-
appPassword := o.config.Core.AppPassword
362
-
363
366
spindleMembers, err := db.GetSpindleMembers(
364
367
o.db,
365
368
db.FilterEq("instance", "spindle.tangled.sh"),
···
375
378
return
376
379
}
377
380
378
-
// TODO: hardcoded tangled handle and did for now
379
-
tangledHandle := "tangled.sh"
380
-
tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli"
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
+
}
387
+
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)
397
+
return
398
+
}
399
+
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
406
+
407
+
allKnots, err := o.enforcer.GetKnotsForUser(did)
408
+
if err != nil {
409
+
log.Printf("failed to get knot members for did %s: %v", did, err)
410
+
return
411
+
}
412
+
413
+
if slices.Contains(allKnots, defaultKnot) {
414
+
log.Printf("did %s is already a member of the default knot", did)
415
+
return
416
+
}
381
417
382
-
if appPassword == "" {
383
-
log.Println("no app password configured, skipping spindle member addition")
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)
384
422
return
385
423
}
386
424
387
-
log.Printf("adding %s to default spindle", did)
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
+
}
388
451
389
452
resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid)
390
453
if err != nil {
391
-
log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
392
-
return
454
+
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
393
455
}
394
456
395
457
pdsEndpoint := resolved.PDSEndpoint()
396
458
if pdsEndpoint == "" {
397
-
log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
398
-
return
459
+
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
399
460
}
400
461
401
462
sessionPayload := map[string]string{
···
404
465
}
405
466
sessionBytes, err := json.Marshal(sessionPayload)
406
467
if err != nil {
407
-
log.Printf("failed to marshal session payload: %v", err)
408
-
return
468
+
return nil, fmt.Errorf("failed to marshal session payload: %v", err)
409
469
}
410
470
411
471
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
412
472
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
413
473
if err != nil {
414
-
log.Printf("failed to create session request: %v", err)
415
-
return
474
+
return nil, fmt.Errorf("failed to create session request: %v", err)
416
475
}
417
476
sessionReq.Header.Set("Content-Type", "application/json")
418
477
419
478
client := &http.Client{Timeout: 30 * time.Second}
420
479
sessionResp, err := client.Do(sessionReq)
421
480
if err != nil {
422
-
log.Printf("failed to create session: %v", err)
423
-
return
481
+
return nil, fmt.Errorf("failed to create session: %v", err)
424
482
}
425
483
defer sessionResp.Body.Close()
426
484
427
485
if sessionResp.StatusCode != http.StatusOK {
428
-
log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode)
429
-
return
486
+
return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
430
487
}
431
488
432
-
var session struct {
433
-
AccessJwt string `json:"accessJwt"`
434
-
}
489
+
var session session
435
490
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
436
-
log.Printf("failed to decode session response: %v", err)
437
-
return
491
+
return nil, fmt.Errorf("failed to decode session response: %v", err)
438
492
}
439
493
440
-
record := tangled.SpindleMember{
441
-
LexiconTypeID: "sh.tangled.spindle.member",
442
-
Subject: did,
443
-
Instance: defaultSpindle,
444
-
CreatedAt: time.Now().Format(time.RFC3339),
445
-
}
494
+
session.PdsEndpoint = pdsEndpoint
495
+
496
+
return &session, nil
497
+
}
446
498
499
+
func (s *session) putRecord(record any) error {
447
500
recordBytes, err := json.Marshal(record)
448
501
if err != nil {
449
-
log.Printf("failed to marshal spindle member record: %v", err)
450
-
return
502
+
return fmt.Errorf("failed to marshal knot member record: %w", err)
451
503
}
452
504
453
-
payload := map[string]interface{}{
505
+
payload := map[string]any{
454
506
"repo": tangledDid,
455
-
"collection": tangled.SpindleMemberNSID,
507
+
"collection": tangled.KnotMemberNSID,
456
508
"rkey": tid.TID(),
457
509
"record": json.RawMessage(recordBytes),
458
510
}
459
511
460
512
payloadBytes, err := json.Marshal(payload)
461
513
if err != nil {
462
-
log.Printf("failed to marshal request payload: %v", err)
463
-
return
514
+
return fmt.Errorf("failed to marshal request payload: %w", err)
464
515
}
465
516
466
-
url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
517
+
url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
467
518
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
468
519
if err != nil {
469
-
log.Printf("failed to create HTTP request: %v", err)
470
-
return
520
+
return fmt.Errorf("failed to create HTTP request: %w", err)
471
521
}
472
522
473
523
req.Header.Set("Content-Type", "application/json")
474
-
req.Header.Set("Authorization", "Bearer "+session.AccessJwt)
524
+
req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
475
525
526
+
client := &http.Client{Timeout: 30 * time.Second}
476
527
resp, err := client.Do(req)
477
528
if err != nil {
478
-
log.Printf("failed to add user to default spindle: %v", err)
479
-
return
529
+
return fmt.Errorf("failed to add user to default Knot: %w", err)
480
530
}
481
531
defer resp.Body.Close()
482
532
483
533
if resp.StatusCode != http.StatusOK {
484
-
log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode)
485
-
return
486
-
}
487
-
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
534
+
return fmt.Errorf("failed to add user to default Knot: HTTP %d", resp.StatusCode)
516
535
}
517
536
518
-
if resp.StatusCode != http.StatusNoContent {
519
-
log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode)
520
-
return
521
-
}
537
+
return nil
522
538
}
+3
appview/oauth/oauth.go
+3
appview/oauth/oauth.go
+17
-25
appview/pages/pages.go
+17
-25
appview/pages/pages.go
···
338
338
return p.execute("user/settings/emails", w, params)
339
339
}
340
340
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)
347
+
}
348
+
341
349
type KnotsParams struct {
342
350
LoggedInUser *oauth.User
343
351
Registrations []db.Registration
···
360
368
}
361
369
362
370
type KnotListingParams struct {
363
-
db.Registration
371
+
*db.Registration
364
372
}
365
373
366
374
func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
367
375
return p.executePlain("knots/fragments/knotListing", w, params)
368
-
}
369
-
370
-
type KnotListingFullParams struct {
371
-
Registrations []db.Registration
372
-
}
373
-
374
-
func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error {
375
-
return p.executePlain("knots/fragments/knotListingFull", w, params)
376
-
}
377
-
378
-
type KnotSecretParams struct {
379
-
Secret string
380
-
}
381
-
382
-
func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error {
383
-
return p.executePlain("knots/fragments/secret", w, params)
384
376
}
385
377
386
378
type SpindlesParams struct {
···
547
539
}
548
540
549
541
type RepoIndexParams struct {
550
-
LoggedInUser *oauth.User
551
-
RepoInfo repoinfo.RepoInfo
552
-
Active string
553
-
TagMap map[string][]string
554
-
CommitsTrunc []*object.Commit
555
-
TagsTrunc []*types.TagReference
556
-
BranchesTrunc []types.Branch
557
-
ForkInfo *types.ForkInfo
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
558
550
HTMLReadme template.HTML
559
551
Raw bool
560
552
EmailToDidOrHandle map[string]string
+93
-28
appview/pages/templates/knots/dashboard.html
+93
-28
appview/pages/templates/knots/dashboard.html
···
1
-
{{ define "title" }}{{ .Registration.Domain }}{{ end }}
1
+
{{ define "title" }}{{ .Registration.Domain }} · knots{{ end }}
2
2
3
3
{{ define "content" }}
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 }}
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 }}
13
+
{{ template "knots/fragments/addMemberModal" .Registration }}
14
+
{{ 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
12
18
</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>
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>
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 }}
21
26
{{ end }}
22
-
</div>
27
+
{{ end }}
28
+
29
+
{{ if $isOwner }}
30
+
{{ block "deleteButton" .Registration }} {{ end }}
31
+
{{ end }}
23
32
</div>
24
-
<div id="operation-error" class="dark:text-red-400"></div>
25
33
</div>
34
+
<div id="operation-error" class="dark:text-red-400"></div>
35
+
</div>
26
36
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 }}
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>
43
+
{{ end }}
34
44
{{ end }}
35
45
36
-
{{ define "knotMember" }}
46
+
47
+
{{ define "member" }}
37
48
{{ range .Members }}
38
49
<div>
39
50
<div class="flex justify-between items-center">
···
41
52
{{ template "user/fragments/picHandleLink" . }}
42
53
<span class="ml-2 font-mono text-gray-500">{{.}}</span>
43
54
</div>
55
+
{{ if ne $.LoggedInUser.Did . }}
56
+
{{ block "removeMemberButton" (list $ . ) }} {{ end }}
57
+
{{ end }}
44
58
</div>
45
59
<div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700">
46
60
{{ $repos := index $.Repos . }}
···
53
67
</div>
54
68
{{ else }}
55
69
<div class="text-gray-500 dark:text-gray-400">
56
-
No repositories created yet.
70
+
No repositories configured yet.
57
71
</div>
58
72
{{ end }}
59
73
</div>
60
74
</div>
61
75
{{ end }}
62
76
{{ 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
+
+6
-7
appview/pages/templates/knots/fragments/addMemberModal.html
+6
-7
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 spindle"
4
+
title="Add member to this knot"
5
5
popovertarget="add-member-{{ .Id }}"
6
6
popovertargetaction="toggle"
7
7
>
···
20
20
21
21
{{ define "addKnotMemberPopover" }}
22
22
<form
23
-
hx-put="/knots/{{ .Domain }}/member"
23
+
hx-post="/knots/{{ .Domain }}/add"
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 on this knot.</p>
31
+
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
32
32
<input
33
33
type="text"
34
34
id="member-did-{{ .Id }}"
35
-
name="subject"
35
+
name="member"
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 }}
58
-
57
+
{{ end }}
+57
-25
appview/pages/templates/knots/fragments/knotListing.html
+57
-25
appview/pages/templates/knots/fragments/knotListing.html
···
1
1
{{ define "knots/fragments/knotListing" }}
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 }}
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 }}
8
5
</div>
9
6
{{ end }}
10
7
11
-
{{ define "listLeftSide" }}
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
+
{{ i "hard-drive" "w-4 h-4" }}
12
+
<span class="hover:underline">
13
+
{{ .Domain }}
14
+
</span>
15
+
<span class="text-gray-500">
16
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
17
+
</span>
18
+
</a>
19
+
{{ else }}
12
20
<div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
13
21
{{ i "hard-drive" "w-4 h-4" }}
14
-
{{ if .Registered }}
15
-
<a href="/knots/{{ .Domain }}">
16
-
{{ .Domain }}
17
-
</a>
18
-
{{ else }}
19
-
{{ .Domain }}
20
-
{{ end }}
22
+
{{ .Domain }}
21
23
<span class="text-gray-500">
22
24
{{ template "repo/fragments/shortTimeAgo" .Created }}
23
25
</span>
24
26
</div>
27
+
{{ end }}
25
28
{{ end }}
26
29
27
-
{{ define "listRightSide" }}
30
+
{{ define "knotRightSide" }}
28
31
<div id="right-side" class="flex gap-2">
29
32
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
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>
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>
32
37
{{ 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 }}
33
45
{{ else }}
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 }}
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 }}
36
51
{{ end }}
37
52
</div>
38
53
{{ end }}
39
54
40
-
{{ define "initializeButton" }}
55
+
{{ define "knotDeleteButton" }}
56
+
<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 }}'?"
63
+
>
64
+
{{ i "trash-2" "w-5 h-5" }}
65
+
<span class="hidden md:inline">delete</span>
66
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
67
+
</button>
68
+
{{ end }}
69
+
70
+
71
+
{{ define "knotRetryButton" }}
41
72
<button
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"
73
+
class="btn gap-2 group"
74
+
title="Retry knot verification"
75
+
hx-post="/knots/{{ .Domain }}/retry"
44
76
hx-swap="none"
77
+
hx-target="#knot-{{.Id}}"
45
78
>
46
-
{{ i "square-play" "w-5 h-5" }}
47
-
<span class="hidden md:inline">initialize</span>
79
+
{{ i "rotate-ccw" "w-5 h-5" }}
80
+
<span class="hidden md:inline">retry</span>
48
81
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
49
82
</button>
50
83
{{ end }}
51
-
-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 }}
+23
-8
appview/pages/templates/knots/index.html
+23
-8
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
-
{{ template "knots/fragments/knotListingFull" . }}
11
+
{{ block "list" . }} {{ end }}
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
+
30
46
{{ define "register" }}
31
-
<section class="rounded max-w-2xl flex flex-col gap-2">
47
+
<section class="rounded w-full lg:w-fit flex flex-col gap-2">
32
48
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2>
33
-
<p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to generate a key.</p>
49
+
<p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p>
34
50
<form
35
-
hx-post="/knots/key"
36
-
class="space-y-4"
51
+
hx-post="/knots/register"
52
+
class="max-w-2xl mb-2 space-y-4"
37
53
hx-indicator="#register-button"
38
54
hx-swap="none"
39
55
>
···
53
69
>
54
70
<span class="inline-flex items-center gap-2">
55
71
{{ i "plus" "w-4 h-4" }}
56
-
generate
72
+
register
57
73
</span>
58
74
<span class="pl-2 hidden group-[.htmx-request]:inline">
59
75
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
···
61
77
</button>
62
78
</div>
63
79
64
-
<div id="registration-error" class="error dark:text-red-400"></div>
80
+
<div id="register-error" class="error dark:text-red-400"></div>
65
81
</form>
66
82
67
-
<div id="secret"></div>
68
83
</section>
69
84
{{ end }}
+7
appview/pages/templates/layouts/topbar.html
+7
appview/pages/templates/layouts/topbar.html
+8
-2
appview/pages/templates/repo/fork.html
+8
-2
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">
8
+
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
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">fork repo</button>
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>
34
40
<div id="repo" class="error"></div>
35
41
</div>
36
42
</form>
-33
appview/pages/templates/repo/index.html
-33
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 }}
120
87
<a
121
88
href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}"
122
89
class="btn flex items-center gap-2 no-underline hover:no-underline"
+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="create-pull-spinner" class="group">
66
+
<span id="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>
+3
-1
appview/pages/templates/repo/settings/general.html
+3
-1
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>
11
12
</div>
12
13
</section>
13
14
{{ end }}
···
22
23
unless you specify a different branch.
23
24
</p>
24
25
</div>
25
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
26
+
<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">
26
27
<select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
27
28
<option value="" disabled selected >
28
29
Choose a default branch
···
54
55
<button
55
56
class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center"
56
57
type="button"
58
+
hx-swap="none"
57
59
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
58
60
hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?">
59
61
{{ i "trash-2" "size-4" }}
+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 "addMemberPopover" . }} {{ end }}
17
+
{{ block "addSpindleMemberPopover" . }} {{ end }}
18
18
</div>
19
19
{{ end }}
20
20
21
-
{{ define "addMemberPopover" }}
21
+
{{ define "addSpindleMemberPopover" }}
22
22
<form
23
23
hx-post="/spindles/{{ .Instance }}/add"
24
24
hx-indicator="#spinner"
+11
-9
appview/pages/templates/spindles/fragments/spindleListing.html
+11
-9
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 "leftSide" . }} {{ end }}
4
-
{{ block "rightSide" . }} {{ end }}
3
+
{{ block "spindleLeftSide" . }} {{ end }}
4
+
{{ block "spindleRightSide" . }} {{ end }}
5
5
</div>
6
6
{{ end }}
7
7
8
-
{{ define "leftSide" }}
8
+
{{ define "spindleLeftSide" }}
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
-
{{ .Instance }}
12
+
<span class="hover:underline">
13
+
{{ .Instance }}
14
+
</span>
13
15
<span class="text-gray-500">
14
16
{{ template "repo/fragments/shortTimeAgo" .Created }}
15
17
</span>
···
25
27
{{ end }}
26
28
{{ end }}
27
29
28
-
{{ define "rightSide" }}
30
+
{{ define "spindleRightSide" }}
29
31
<div id="right-side" class="flex gap-2">
30
32
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
31
33
{{ if .Verified }}
···
33
35
{{ template "spindles/fragments/addMemberModal" . }}
34
36
{{ else }}
35
37
<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>
36
-
{{ block "retryButton" . }} {{ end }}
38
+
{{ block "spindleRetryButton" . }} {{ end }}
37
39
{{ end }}
38
-
{{ block "deleteButton" . }} {{ end }}
40
+
{{ block "spindleDeleteButton" . }} {{ end }}
39
41
</div>
40
42
{{ end }}
41
43
42
-
{{ define "deleteButton" }}
44
+
{{ define "spindleDeleteButton" }}
43
45
<button
44
46
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
45
47
title="Delete spindle"
···
55
57
{{ end }}
56
58
57
59
58
-
{{ define "retryButton" }}
60
+
{{ define "spindleRetryButton" }}
59
61
<button
60
62
class="btn gap-2 group"
61
63
title="Retry spindle verification"
+10
-10
appview/pages/templates/user/settings/profile.html
+10
-10
appview/pages/templates/user/settings/profile.html
···
30
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
31
<div class="flex items-center justify-between p-4">
32
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>
33
36
{{ if .LoggedInUser.Handle }}
34
37
<span class="font-bold">
35
38
@{{ .LoggedInUser.Handle }}
36
39
</span>
37
-
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
38
-
<span>Handle</span>
39
-
</div>
40
40
{{ end }}
41
41
</div>
42
42
</div>
43
43
<div class="flex items-center justify-between p-4">
44
44
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
45
-
<span class="font-mono text-xs">
46
-
{{ .LoggedInUser.Did }}
47
-
</span>
48
-
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
45
+
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
49
46
<span>Decentralized Identifier (DID)</span>
50
47
</div>
48
+
<span class="font-mono font-bold">
49
+
{{ .LoggedInUser.Did }}
50
+
</span>
51
51
</div>
52
52
</div>
53
53
<div class="flex items-center justify-between p-4">
54
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>
55
58
<span class="font-bold">
56
59
{{ .LoggedInUser.Pds }}
57
60
</span>
58
-
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
59
-
<span>Personal Data Server (PDS)</span>
60
-
</div>
61
61
</div>
62
62
</div>
63
63
</div>
+113
-92
appview/pulls/pulls.go
+113
-92
appview/pulls/pulls.go
···
2
2
3
3
import (
4
4
"database/sql"
5
-
"encoding/json"
6
5
"errors"
7
6
"fmt"
8
-
"io"
9
7
"log"
10
8
"net/http"
11
9
"sort"
···
21
19
"tangled.sh/tangled.sh/core/appview/pages"
22
20
"tangled.sh/tangled.sh/core/appview/pages/markup"
23
21
"tangled.sh/tangled.sh/core/appview/reporesolver"
22
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
24
23
"tangled.sh/tangled.sh/core/idresolver"
25
24
"tangled.sh/tangled.sh/core/knotclient"
26
25
"tangled.sh/tangled.sh/core/patchutil"
···
30
29
"github.com/bluekeyes/go-gitdiff/gitdiff"
31
30
comatproto "github.com/bluesky-social/indigo/api/atproto"
32
31
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(f, pull, stack)
99
+
mergeCheckResponse := s.mergeCheck(r, 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(f, pull, stack)
154
+
mergeCheckResponse := s.mergeCheck(r, 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(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
218
+
func (s *Pulls) mergeCheck(r *http.Request, 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
-
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
-
}
223
+
scheme := "https"
224
+
if s.config.Core.Dev {
225
+
scheme = "http"
229
226
}
227
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
230
228
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
-
}
229
+
xrpcc := indigoxrpc.Client{
230
+
Host: host,
237
231
}
238
232
239
233
patch := pull.LatestPatch()
···
246
240
patch = mergeable.CombinedPatch()
247
241
}
248
242
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)
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)
252
255
return types.MergeCheckResponse{
253
-
Error: "failed to check merge status",
256
+
Error: fmt.Sprintf("failed to check merge status: %s", err.Error()),
254
257
}
255
258
}
256
-
switch resp.StatusCode {
257
-
case 404:
258
-
return types.MergeCheckResponse{
259
-
Error: "failed to check merge status: this knot does not support PRs",
260
-
}
261
-
case 400:
262
-
return types.MergeCheckResponse{
263
-
Error: "failed to check merge status: does this knot support PRs?",
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,
264
266
}
265
267
}
266
268
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
-
}
269
+
result := types.MergeCheckResponse{
270
+
IsConflicted: resp.Is_conflicted,
271
+
Conflicts: conflicts,
272
+
}
273
+
274
+
if resp.Message != nil {
275
+
result.Message = *resp.Message
273
276
}
274
-
defer resp.Body.Close()
275
277
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
-
}
278
+
if resp.Error != nil {
279
+
result.Error = *resp.Error
283
280
}
284
281
285
-
return mergeCheckResponse
282
+
return result
286
283
}
287
284
288
285
func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
···
867
864
return
868
865
}
869
866
870
-
secret, err := db.GetRegistrationKey(s.db, fork.Knot)
871
-
if err != nil {
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)
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
+
)
878
873
if err != nil {
879
-
log.Println("failed to create signed client:", err)
874
+
log.Printf("failed to connect to knot server: %v", err)
880
875
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
881
876
return
882
877
}
···
888
883
return
889
884
}
890
885
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.")
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())
895
897
return
896
898
}
897
899
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.")
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)
902
906
return
903
907
}
904
908
···
1464
1468
return
1465
1469
}
1466
1470
1467
-
secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
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
+
)
1468
1478
if err != nil {
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.")
1479
+
log.Printf("failed to connect to knot server: %v", err)
1471
1480
return
1472
1481
}
1473
1482
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.")
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())
1479
1494
return
1480
1495
}
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.")
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.")
1486
1499
return
1487
1500
}
1488
1501
···
1908
1921
1909
1922
patch := pullsToMerge.CombinedPatch()
1910
1923
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
-
1918
1924
ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid)
1919
1925
if err != nil {
1920
1926
log.Printf("resolving identity: %s", err)
···
1927
1933
log.Printf("failed to get primary email: %s", err)
1928
1934
}
1929
1935
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
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,
1935
1944
}
1936
1945
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)
1946
+
if pull.Body != "" {
1947
+
mergeInput.CommitBody = &pull.Body
1948
+
}
1949
+
1950
+
if email.Address != "" {
1951
+
mergeInput.AuthorEmail = &email.Address
1952
+
}
1953
+
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
+
)
1939
1960
if err != nil {
1940
-
log.Printf("failed to merge pull request: %s", err)
1961
+
log.Printf("failed to connect to knot server: %v", err)
1941
1962
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1942
1963
return
1943
1964
}
1944
1965
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.")
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())
1948
1969
return
1949
1970
}
1950
1971
+10
-100
appview/repo/index.go
+10
-100
appview/repo/index.go
···
1
1
package repo
2
2
3
3
import (
4
-
"encoding/json"
5
-
"fmt"
6
4
"log"
7
5
"net/http"
8
6
"slices"
···
11
9
12
10
"tangled.sh/tangled.sh/core/appview/commitverify"
13
11
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/oauth"
15
12
"tangled.sh/tangled.sh/core/appview/pages"
16
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
17
13
"tangled.sh/tangled.sh/core/appview/reporesolver"
18
14
"tangled.sh/tangled.sh/core/knotclient"
19
15
"tangled.sh/tangled.sh/core/types"
···
105
101
user := rp.oauth.GetUser(r)
106
102
repoInfo := f.RepoInfo(user)
107
103
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
-
129
104
// TODO: a bit dirty
130
-
languageInfo, err := rp.getLanguageInfo(f, signedClient, result.Ref, ref == "")
105
+
languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "")
131
106
if err != nil {
132
107
log.Printf("failed to compute language percentages: %s", err)
133
108
// non-fatal
···
144
119
}
145
120
146
121
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
147
-
LoggedInUser: user,
148
-
RepoInfo: repoInfo,
149
-
TagMap: tagMap,
150
-
RepoIndexResponse: *result,
151
-
CommitsTrunc: commitsTrunc,
152
-
TagsTrunc: tagsTrunc,
153
-
ForkInfo: forkInfo,
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
154
129
BranchesTrunc: branchesTrunc,
155
130
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
156
131
VerifiedCommits: vc,
···
161
136
162
137
func (rp *Repo) getLanguageInfo(
163
138
f *reporesolver.ResolvedRepo,
164
-
signedClient *knotclient.SignedClient,
139
+
us *knotclient.UnsignedClient,
165
140
currentRef string,
166
141
isDefaultRef bool,
167
142
) ([]types.RepoLanguageDetails, error) {
···
174
149
175
150
if err != nil || langs == nil {
176
151
// non-fatal, fetch langs from ks
177
-
ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, currentRef)
152
+
ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef)
178
153
if err != nil {
179
154
return nil, err
180
155
}
···
231
206
232
207
return languageStats, nil
233
208
}
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
-
}
+194
-203
appview/repo/repo.go
+194
-203
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"
20
22
"tangled.sh/tangled.sh/core/api/tangled"
21
23
"tangled.sh/tangled.sh/core/appview/commitverify"
22
24
"tangled.sh/tangled.sh/core/appview/config"
···
26
28
"tangled.sh/tangled.sh/core/appview/pages"
27
29
"tangled.sh/tangled.sh/core/appview/pages/markup"
28
30
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
+
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
29
32
"tangled.sh/tangled.sh/core/eventconsumer"
30
33
"tangled.sh/tangled.sh/core/idresolver"
31
34
"tangled.sh/tangled.sh/core/knotclient"
···
33
36
"tangled.sh/tangled.sh/core/rbac"
34
37
"tangled.sh/tangled.sh/core/tid"
35
38
"tangled.sh/tangled.sh/core/types"
39
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
36
40
37
41
securejoin "github.com/cyphar/filepath-securejoin"
38
42
"github.com/go-chi/chi/v5"
39
43
"github.com/go-git/go-git/v5/plumbing"
40
44
41
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
42
45
"github.com/bluesky-social/indigo/atproto/syntax"
43
-
lexutil "github.com/bluesky-social/indigo/lex/util"
44
46
)
45
47
46
48
type Repo struct {
···
54
56
enforcer *rbac.Enforcer
55
57
notifier notify.Notifier
56
58
logger *slog.Logger
59
+
serviceAuth *serviceauth.ServiceAuth
57
60
}
58
61
59
62
func New(
···
860
863
fail("Failed to write record to PDS.", err)
861
864
return
862
865
}
863
-
l = l.With("at-uri", resp.Uri)
866
+
867
+
aturi := resp.Uri
868
+
l = l.With("at-uri", aturi)
864
869
l.Info("wrote record to PDS")
865
870
866
-
l.Info("adding to knot")
867
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
871
+
tx, err := rp.db.BeginTx(r.Context(), nil)
868
872
if err != nil {
869
-
fail("Failed to add to knot.", err)
873
+
fail("Failed to add collaborator.", err)
870
874
return
871
875
}
872
876
873
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
874
-
if err != nil {
875
-
fail("Failed to add to knot.", err)
876
-
return
877
-
}
877
+
rollback := func() {
878
+
err1 := tx.Rollback()
879
+
err2 := rp.enforcer.E.LoadPolicy()
880
+
err3 := rollbackRecord(context.Background(), aturi, client)
878
881
879
-
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String())
880
-
if err != nil {
881
-
fail("Knot was unreachable.", err)
882
-
return
883
-
}
882
+
// ignore txn complete errors, this is okay
883
+
if errors.Is(err1, sql.ErrTxDone) {
884
+
err1 = nil
885
+
}
884
886
885
-
if ksResp.StatusCode != http.StatusNoContent {
886
-
fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil)
887
-
return
887
+
if errs := errors.Join(err1, err2, err3); errs != nil {
888
+
l.Error("failed to rollback changes", "errs", errs)
889
+
return
890
+
}
888
891
}
889
-
890
-
tx, err := rp.db.BeginTx(r.Context(), nil)
891
-
if err != nil {
892
-
fail("Failed to add collaborator.", err)
893
-
return
894
-
}
895
-
defer func() {
896
-
tx.Rollback()
897
-
err = rp.enforcer.E.LoadPolicy()
898
-
if err != nil {
899
-
fail("Failed to add collaborator.", err)
900
-
}
901
-
}()
892
+
defer rollback()
902
893
903
894
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
904
895
if err != nil {
···
930
921
return
931
922
}
932
923
924
+
// clear aturi to when everything is successful
925
+
aturi = ""
926
+
933
927
rp.pages.HxRefresh(w)
934
928
}
935
929
936
930
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
937
931
user := rp.oauth.GetUser(r)
938
932
933
+
noticeId := "operation-error"
939
934
f, err := rp.repoResolver.Resolve(r)
940
935
if err != nil {
941
936
log.Println("failed to get repo and knot", err)
···
955
950
})
956
951
if err != nil {
957
952
log.Printf("failed to delete record: %s", err)
958
-
rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
953
+
rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
959
954
return
960
955
}
961
956
log.Println("removed repo record ", f.RepoAt().String())
962
957
963
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
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
+
)
964
964
if err != nil {
965
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
965
+
log.Println("failed to connect to knot server:", err)
966
966
return
967
967
}
968
968
969
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
970
-
if err != nil {
971
-
log.Println("failed to create client to ", f.Knot)
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())
972
980
return
973
981
}
974
-
975
-
ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.Name)
976
-
if err != nil {
977
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
978
-
return
979
-
}
980
-
981
-
if ksResp.StatusCode != http.StatusNoContent {
982
-
log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
983
-
} else {
984
-
log.Println("removed repo from knot ", f.Knot)
985
-
}
982
+
log.Println("deleted repo from knot")
986
983
987
984
tx, err := rp.db.BeginTx(r.Context(), nil)
988
985
if err != nil {
···
1001
998
// remove collaborator RBAC
1002
999
repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
1003
1000
if err != nil {
1004
-
rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
1001
+
rp.pages.Notice(w, noticeId, "Failed to remove collaborators")
1005
1002
return
1006
1003
}
1007
1004
for _, c := range repoCollaborators {
···
1013
1010
// remove repo RBAC
1014
1011
err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
1015
1012
if err != nil {
1016
-
rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
1013
+
rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
1017
1014
return
1018
1015
}
1019
1016
1020
1017
// remove repo from db
1021
1018
err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
1022
1019
if err != nil {
1023
-
rp.pages.Notice(w, "settings-delete", "Failed to update appview")
1020
+
rp.pages.Notice(w, noticeId, "Failed to update appview")
1024
1021
return
1025
1022
}
1026
1023
log.Println("removed repo from db")
···
1049
1046
return
1050
1047
}
1051
1048
1049
+
noticeId := "operation-error"
1052
1050
branch := r.FormValue("branch")
1053
1051
if branch == "" {
1054
1052
http.Error(w, "malformed form", http.StatusBadRequest)
1055
1053
return
1056
1054
}
1057
1055
1058
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1059
-
if err != nil {
1060
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
1061
-
return
1062
-
}
1063
-
1064
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1065
-
if err != nil {
1066
-
log.Println("failed to create client to ", f.Knot)
1067
-
return
1068
-
}
1069
-
1070
-
ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch)
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
+
)
1071
1062
if err != nil {
1072
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
1063
+
log.Println("failed to connect to knot server:", err)
1064
+
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1073
1065
return
1074
1066
}
1075
1067
1076
-
if ksResp.StatusCode != http.StatusNoContent {
1077
-
rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
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())
1078
1079
return
1079
1080
}
1080
1081
1081
-
w.Write(fmt.Append(nil, "default branch set to: ", branch))
1082
+
rp.pages.HxRefresh(w)
1082
1083
}
1083
1084
1084
1085
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
···
1194
1195
case "pipelines":
1195
1196
rp.pipelineSettings(w, r)
1196
1197
}
1197
-
1198
-
// user := rp.oauth.GetUser(r)
1199
-
// repoCollaborators, err := f.Collaborators(r.Context())
1200
-
// if err != nil {
1201
-
// log.Println("failed to get collaborators", err)
1202
-
// }
1203
-
1204
-
// isCollaboratorInviteAllowed := false
1205
-
// if user != nil {
1206
-
// ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
1207
-
// if err == nil && ok {
1208
-
// isCollaboratorInviteAllowed = true
1209
-
// }
1210
-
// }
1211
-
1212
-
// us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1213
-
// if err != nil {
1214
-
// log.Println("failed to create unsigned client", err)
1215
-
// return
1216
-
// }
1217
-
1218
-
// result, err := us.Branches(f.OwnerDid(), f.Name)
1219
-
// if err != nil {
1220
-
// log.Println("failed to reach knotserver", err)
1221
-
// return
1222
-
// }
1223
-
1224
-
// // all spindles that this user is a member of
1225
-
// spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1226
-
// if err != nil {
1227
-
// log.Println("failed to fetch spindles", err)
1228
-
// return
1229
-
// }
1230
-
1231
-
// var secrets []*tangled.RepoListSecrets_Secret
1232
-
// if f.Spindle != "" {
1233
-
// if spindleClient, err := rp.oauth.ServiceClient(
1234
-
// r,
1235
-
// oauth.WithService(f.Spindle),
1236
-
// oauth.WithLxm(tangled.RepoListSecretsNSID),
1237
-
// oauth.WithDev(rp.config.Core.Dev),
1238
-
// ); err != nil {
1239
-
// log.Println("failed to create spindle client", err)
1240
-
// } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1241
-
// log.Println("failed to fetch secrets", err)
1242
-
// } else {
1243
-
// secrets = resp.Secrets
1244
-
// }
1245
-
// }
1246
-
1247
-
// rp.pages.RepoSettings(w, pages.RepoSettingsParams{
1248
-
// LoggedInUser: user,
1249
-
// RepoInfo: f.RepoInfo(user),
1250
-
// Collaborators: repoCollaborators,
1251
-
// IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1252
-
// Branches: result.Branches,
1253
-
// Spindles: spindles,
1254
-
// CurrentSpindle: f.Spindle,
1255
-
// Secrets: secrets,
1256
-
// })
1257
1198
}
1258
1199
1259
1200
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
···
1373
1314
1374
1315
switch r.Method {
1375
1316
case http.MethodPost:
1376
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
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
+
)
1377
1323
if err != nil {
1378
-
rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot))
1324
+
rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1379
1325
return
1380
1326
}
1381
1327
1382
-
client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1383
-
if err != nil {
1384
-
rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1328
+
repoInfo := f.RepoInfo(user)
1329
+
if repoInfo.Source == nil {
1330
+
rp.pages.Notice(w, "repo", "This repository is not a fork.")
1385
1331
return
1386
1332
}
1387
1333
1388
-
var uri string
1389
-
if rp.config.Core.Dev {
1390
-
uri = "http"
1391
-
} else {
1392
-
uri = "https"
1393
-
}
1394
-
forkName := fmt.Sprintf("%s", f.Name)
1395
-
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1396
-
1397
-
_, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref)
1398
-
if err != nil {
1399
-
rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
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())
1400
1346
return
1401
1347
}
1402
1348
···
1429
1375
})
1430
1376
1431
1377
case http.MethodPost:
1378
+
l := rp.logger.With("handler", "ForkRepo")
1432
1379
1433
-
knot := r.FormValue("knot")
1434
-
if knot == "" {
1380
+
targetKnot := r.FormValue("knot")
1381
+
if targetKnot == "" {
1435
1382
rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1436
1383
return
1437
1384
}
1385
+
l = l.With("targetKnot", targetKnot)
1438
1386
1439
-
ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1387
+
ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create")
1440
1388
if err != nil || !ok {
1441
1389
rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1442
1390
return
1443
1391
}
1444
1392
1445
-
forkName := fmt.Sprintf("%s", f.Name)
1446
-
1393
+
// choose a name for a fork
1394
+
forkName := f.Name
1447
1395
// this check is *only* to see if the forked repo name already exists
1448
1396
// in the user's account.
1449
1397
existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
···
1459
1407
// repo with this name already exists, append random string
1460
1408
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1461
1409
}
1462
-
secret, err := db.GetRegistrationKey(rp.db, knot)
1463
-
if err != nil {
1464
-
rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1465
-
return
1466
-
}
1410
+
l = l.With("forkName", forkName)
1467
1411
1468
-
client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev)
1469
-
if err != nil {
1470
-
rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1471
-
return
1472
-
}
1473
-
1474
-
var uri string
1412
+
uri := "https"
1475
1413
if rp.config.Core.Dev {
1476
1414
uri = "http"
1477
-
} else {
1478
-
uri = "https"
1479
1415
}
1416
+
1480
1417
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1418
+
l = l.With("cloneUrl", forkSourceUrl)
1419
+
1481
1420
sourceAt := f.RepoAt().String()
1482
1421
1422
+
// create an atproto record for this fork
1483
1423
rkey := tid.TID()
1484
1424
repo := &db.Repo{
1485
1425
Did: user.Did,
1486
1426
Name: forkName,
1487
-
Knot: knot,
1427
+
Knot: targetKnot,
1488
1428
Rkey: rkey,
1489
1429
Source: sourceAt,
1490
1430
}
1491
1431
1492
-
tx, err := rp.db.BeginTx(r.Context(), nil)
1493
-
if err != nil {
1494
-
log.Println(err)
1495
-
rp.pages.Notice(w, "repo", "Failed to save repository information.")
1496
-
return
1497
-
}
1498
-
defer func() {
1499
-
tx.Rollback()
1500
-
err = rp.enforcer.E.LoadPolicy()
1501
-
if err != nil {
1502
-
log.Println("failed to rollback policies")
1503
-
}
1504
-
}()
1505
-
1506
-
resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1507
-
if err != nil {
1508
-
rp.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1509
-
return
1510
-
}
1511
-
1512
-
switch resp.StatusCode {
1513
-
case http.StatusConflict:
1514
-
rp.pages.Notice(w, "repo", "A repository with that name already exists.")
1515
-
return
1516
-
case http.StatusInternalServerError:
1517
-
rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1518
-
case http.StatusNoContent:
1519
-
// continue
1520
-
}
1521
-
1522
1432
xrpcClient, err := rp.oauth.AuthorizedClient(r)
1523
1433
if err != nil {
1524
-
log.Println("failed to get authorized client", err)
1525
-
rp.pages.Notice(w, "repo", "Failed to create repository.")
1434
+
l.Error("failed to create xrpcclient", "err", err)
1435
+
rp.pages.Notice(w, "repo", "Failed to fork repository.")
1526
1436
return
1527
1437
}
1528
1438
···
1541
1451
}},
1542
1452
})
1543
1453
if err != nil {
1544
-
log.Printf("failed to create record: %s", err)
1454
+
l.Error("failed to write to PDS", "err", err)
1545
1455
rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1546
1456
return
1547
1457
}
1548
-
log.Println("created repo record: ", atresp.Uri)
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
+
}
1549
1515
1550
1516
err = db.AddRepo(tx, repo)
1551
1517
if err != nil {
···
1556
1522
1557
1523
// acls
1558
1524
p, _ := securejoin.SecureJoin(user.Did, forkName)
1559
-
err = rp.enforcer.AddRepo(user.Did, knot, p)
1525
+
err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
1560
1526
if err != nil {
1561
1527
log.Println(err)
1562
1528
rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
···
1577
1543
return
1578
1544
}
1579
1545
1546
+
// reset the ATURI because the transaction completed successfully
1547
+
aturi = ""
1548
+
1549
+
rp.notifier.NewRepo(r.Context(), repo)
1580
1550
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1581
-
return
1582
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
1560
+
}
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
1583
1574
}
1584
1575
1585
1576
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
+
}
+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
-
verify "tangled.sh/tangled.sh/core/appview/spindleverify"
18
+
"tangled.sh/tangled.sh/core/appview/serververify"
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 = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
230
+
err = serververify.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 = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
237
+
_, err = serververify.MarkSpindleVerified(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 = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
403
+
err = serververify.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, verify.FetchError) {
408
-
s.Pages.Notice(w, noticeId, err.Error())
407
+
if errors.Is(err, serververify.FetchError) {
408
+
s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.")
409
409
return
410
410
}
411
411
412
-
if e, ok := err.(*verify.OwnerMismatch); ok {
412
+
if e, ok := err.(*serververify.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 := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
421
+
rowId, err := serververify.MarkSpindleVerified(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
-
}
+5
-2
appview/state/knotstream.go
+5
-2
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.GetCompletedRegistrations(d)
27
+
knots, err := db.GetRegistrations(
28
+
d,
29
+
db.FilterIsNot("registered", "null"),
30
+
)
28
31
if err != nil {
29
32
return nil, err
30
33
}
31
34
32
35
srcs := make(map[ec.Source]struct{})
33
36
for _, k := range knots {
34
-
s := ec.NewKnotSource(k)
37
+
s := ec.NewKnotSource(k.Domain)
35
38
srcs[s] = struct{}{}
36
39
}
37
40
+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(mw))
150
+
r.Mount("/knots", s.KnotsRouter())
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(mw *middleware.Middleware) http.Handler {
198
+
func (s *State) KnotsRouter() 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(mw)
212
+
return knots.Router()
213
213
}
214
214
215
215
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
+95
-39
appview/state/state.go
+95
-39
appview/state/state.go
···
2
2
3
3
import (
4
4
"context"
5
+
"database/sql"
6
+
"errors"
5
7
"fmt"
6
8
"log"
7
9
"log/slog"
···
10
12
"time"
11
13
12
14
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
16
lexutil "github.com/bluesky-social/indigo/lex/util"
14
17
securejoin "github.com/cyphar/filepath-securejoin"
15
18
"github.com/go-chi/chi/v5"
···
25
28
"tangled.sh/tangled.sh/core/appview/pages"
26
29
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
27
30
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
+
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
28
32
"tangled.sh/tangled.sh/core/eventconsumer"
29
33
"tangled.sh/tangled.sh/core/idresolver"
30
34
"tangled.sh/tangled.sh/core/jetstream"
31
-
"tangled.sh/tangled.sh/core/knotclient"
32
35
tlog "tangled.sh/tangled.sh/core/log"
33
36
"tangled.sh/tangled.sh/core/rbac"
34
37
"tangled.sh/tangled.sh/core/tid"
38
+
// xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
35
39
)
36
40
37
41
type State struct {
···
48
52
repoResolver *reporesolver.RepoResolver
49
53
knotstream *eventconsumer.Consumer
50
54
spindlestream *eventconsumer.Consumer
55
+
logger *slog.Logger
51
56
}
52
57
53
58
func Make(ctx context.Context, config *config.Config) (*State, error) {
···
152
157
repoResolver,
153
158
knotstream,
154
159
spindlestream,
160
+
slog.Default(),
155
161
}
156
162
157
163
return state, nil
···
291
297
})
292
298
293
299
case http.MethodPost:
300
+
l := s.logger.With("handler", "NewRepo")
301
+
294
302
user := s.oauth.GetUser(r)
303
+
l = l.With("did", user.Did)
304
+
l = l.With("handle", user.Handle)
295
305
306
+
// form validation
296
307
domain := r.FormValue("domain")
297
308
if domain == "" {
298
309
s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
299
310
return
300
311
}
312
+
l = l.With("knot", domain)
301
313
302
314
repoName := r.FormValue("name")
303
315
if repoName == "" {
···
309
321
s.pages.Notice(w, "repo", err.Error())
310
322
return
311
323
}
312
-
313
324
repoName = stripGitExt(repoName)
325
+
l = l.With("repoName", repoName)
314
326
315
327
defaultBranch := r.FormValue("branch")
316
328
if defaultBranch == "" {
317
329
defaultBranch = "main"
318
330
}
331
+
l = l.With("defaultBranch", defaultBranch)
319
332
320
333
description := r.FormValue("description")
321
334
335
+
// ACL validation
322
336
ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
323
337
if err != nil || !ok {
338
+
l.Info("unauthorized")
324
339
s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
325
340
return
326
341
}
327
342
343
+
// Check for existing repos
328
344
existingRepo, err := db.GetRepo(s.db, user.Did, repoName)
329
345
if err == nil && existingRepo != nil {
330
-
s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot))
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))
331
348
return
332
349
}
333
350
334
-
secret, err := db.GetRegistrationKey(s.db, domain)
335
-
if err != nil {
336
-
s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
337
-
return
338
-
}
339
-
340
-
client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
341
-
if err != nil {
342
-
s.pages.Notice(w, "repo", "Failed to connect to knot server.")
343
-
return
344
-
}
345
-
351
+
// create atproto record for this repo
346
352
rkey := tid.TID()
347
353
repo := &db.Repo{
348
354
Did: user.Did,
···
354
360
355
361
xrpcClient, err := s.oauth.AuthorizedClient(r)
356
362
if err != nil {
363
+
l.Info("PDS write failed", "err", err)
357
364
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
358
365
return
359
366
}
···
372
379
}},
373
380
})
374
381
if err != nil {
375
-
log.Printf("failed to create record: %s", err)
382
+
l.Info("PDS write failed", "err", err)
376
383
s.pages.Notice(w, "repo", "Failed to announce repository creation.")
377
384
return
378
385
}
379
-
log.Println("created repo record: ", atresp.Uri)
386
+
387
+
aturi := atresp.Uri
388
+
l = l.With("aturi", aturi)
389
+
l.Info("wrote to PDS")
380
390
381
391
tx, err := s.db.BeginTx(r.Context(), nil)
382
392
if err != nil {
383
-
log.Println(err)
393
+
l.Info("txn failed", "err", err)
384
394
s.pages.Notice(w, "repo", "Failed to save repository information.")
385
395
return
386
396
}
387
-
defer func() {
388
-
tx.Rollback()
389
-
err = s.enforcer.E.LoadPolicy()
390
-
if err != nil {
391
-
log.Println("failed to rollback policies")
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
392
410
}
393
-
}()
394
411
395
-
resp, err := client.NewRepo(user.Did, repoName, defaultBranch)
412
+
if errs := errors.Join(err1, err2, err3); errs != nil {
413
+
l.Error("failed to rollback changes", "errs", errs)
414
+
return
415
+
}
416
+
}
417
+
defer rollback()
418
+
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
+
)
396
425
if err != nil {
397
-
s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
426
+
l.Error("service auth failed", "err", err)
427
+
s.pages.Notice(w, "repo", "Failed to reach PDS.")
398
428
return
399
429
}
400
430
401
-
switch resp.StatusCode {
402
-
case http.StatusConflict:
403
-
s.pages.Notice(w, "repo", "A repository with that name already exists.")
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())
404
441
return
405
-
case http.StatusInternalServerError:
406
-
s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
407
-
case http.StatusNoContent:
408
-
// continue
409
442
}
410
443
411
444
err = db.AddRepo(tx, repo)
412
445
if err != nil {
413
-
log.Println(err)
446
+
l.Error("db write failed", "err", err)
414
447
s.pages.Notice(w, "repo", "Failed to save repository information.")
415
448
return
416
449
}
···
419
452
p, _ := securejoin.SecureJoin(user.Did, repoName)
420
453
err = s.enforcer.AddRepo(user.Did, domain, p)
421
454
if err != nil {
422
-
log.Println(err)
455
+
l.Error("acl setup failed", "err", err)
423
456
s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
424
457
return
425
458
}
426
459
427
460
err = tx.Commit()
428
461
if err != nil {
429
-
log.Println("failed to commit changes", err)
462
+
l.Error("txn commit failed", "err", err)
430
463
http.Error(w, err.Error(), http.StatusInternalServerError)
431
464
return
432
465
}
433
466
434
467
err = s.enforcer.E.SavePolicy()
435
468
if err != nil {
436
-
log.Println("failed to update ACLs", err)
469
+
l.Error("acl save failed", "err", err)
437
470
http.Error(w, err.Error(), http.StatusInternalServerError)
438
471
return
439
472
}
473
+
474
+
// reset the ATURI because the transaction completed successfully
475
+
aturi = ""
440
476
441
477
s.notifier.NewRepo(r.Context(), repo)
478
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
479
+
}
480
+
}
442
481
443
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
444
-
return
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
488
}
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
446
502
}
+25
appview/xrpcclient/xrpc.go
+25
appview/xrpcclient/xrpc.go
···
3
3
import (
4
4
"bytes"
5
5
"context"
6
+
"errors"
7
+
"fmt"
6
8
"io"
9
+
"net/http"
7
10
8
11
"github.com/bluesky-social/indigo/api/atproto"
9
12
"github.com/bluesky-social/indigo/xrpc"
13
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
10
14
oauth "tangled.sh/icyphox.sh/atproto-oauth"
11
15
)
12
16
···
102
106
103
107
return &out, nil
104
108
}
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, 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.
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.
63
61
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.
62
+
If you don't want to [set up a spindle](#running-a-spindle),
63
+
you can use any placeholder value.
68
64
69
65
You can now start a lightweight NixOS VM like so:
70
66
···
75
71
```
76
72
77
73
This starts a knot on port 6000, a spindle on port 6555
78
-
with `ssh` exposed on port 2222. You can push repositories
79
-
to this VM with this ssh config block on your main machine:
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:
80
84
81
85
```bash
82
86
Host nixos-shell
···
95
99
96
100
## running a spindle
97
101
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.
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.
106
106
107
107
Of interest when debugging spindles:
108
108
+7
-5
docs/knot-hosting.md
+7
-5
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_SECRET` can be obtained from the
77
-
[/knots](https://tangled.sh/knots) page on Tangled.
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.
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_SECRET=secret
83
+
KNOT_SERVER_OWNER=did:plc:foobar
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 your registration by hitting the
132
-
`initialize` button on the [/knots](https://tangled.sh/knots) page.
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.
133
135
134
136
### custom paths
135
137
+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 api/tangled/*
255
+
rm -f 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"`
21
20
DBPath string `env:"DB_PATH, default=knotserver.db"`
22
21
Hostname string `env:"HOSTNAME, required"`
23
22
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.
+1008
-150
knotserver/handler.go
+1008
-150
knotserver/handler.go
···
1
1
package knotserver
2
2
3
3
import (
4
+
"compress/gzip"
4
5
"context"
6
+
"crypto/sha256"
7
+
"encoding/json"
8
+
"errors"
5
9
"fmt"
6
-
"log/slog"
10
+
"log"
7
11
"net/http"
8
-
"runtime/debug"
12
+
"net/url"
13
+
"path/filepath"
14
+
"strconv"
15
+
"strings"
16
+
"sync"
17
+
"time"
9
18
19
+
securejoin "github.com/cyphar/filepath-securejoin"
20
+
"github.com/gliderlabs/ssh"
10
21
"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"
22
+
"github.com/go-git/go-git/v5/plumbing"
23
+
"github.com/go-git/go-git/v5/plumbing/object"
14
24
"tangled.sh/tangled.sh/core/knotserver/db"
15
-
"tangled.sh/tangled.sh/core/knotserver/xrpc"
16
-
tlog "tangled.sh/tangled.sh/core/log"
17
-
"tangled.sh/tangled.sh/core/notifier"
18
-
"tangled.sh/tangled.sh/core/rbac"
25
+
"tangled.sh/tangled.sh/core/knotserver/git"
26
+
"tangled.sh/tangled.sh/core/types"
19
27
)
20
28
21
-
type Handle struct {
22
-
c *config.Config
23
-
db *db.DB
24
-
jc *jetstream.JetstreamClient
25
-
e *rbac.Enforcer
26
-
l *slog.Logger
27
-
n *notifier.Notifier
28
-
resolver *idresolver.Resolver
29
+
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
+
}
29
45
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
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)
34
53
}
35
54
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()
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()
38
70
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{}),
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
+
}
48
85
}
49
86
50
-
err := e.AddKnot(rbac.ThisServer)
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
+
}()
152
+
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)
219
+
}
220
+
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)
225
+
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)
51
230
if err != nil {
52
-
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
231
+
notFound(w)
232
+
return
53
233
}
54
234
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()
235
+
files, err := gr.FileTree(r.Context(), treePath)
59
236
if err != nil {
60
-
return nil, fmt.Errorf("failed to get all Dids: %w", err)
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,
248
+
}
249
+
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)
262
+
if err != nil {
263
+
notFound(w)
264
+
return
265
+
}
266
+
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"
61
279
}
62
280
63
-
if len(dids) > 0 {
64
-
h.knotInitialized = true
65
-
close(h.init)
66
-
for _, d := range dids {
67
-
h.jc.AddDid(d)
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
68
290
}
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
69
300
}
70
301
71
-
err = h.jc.StartJetstream(ctx, h.processMessages)
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)
315
+
if err != nil {
316
+
notFound(w)
317
+
return
318
+
}
319
+
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
+
}
331
+
332
+
bytes := []byte(contents)
333
+
// safe := string(sanitize(bytes))
334
+
sizeHint := len(bytes)
335
+
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)
352
+
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
+
}
358
+
359
+
ref := strings.TrimSuffix(file, ".tar.gz")
360
+
361
+
unescapedRef, err := url.PathUnescape(ref)
72
362
if err != nil {
73
-
return nil, fmt.Errorf("failed to start jetstream: %w", err)
363
+
notFound(w)
364
+
return
74
365
}
75
366
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
-
})
367
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
86
368
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
-
})
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)
92
374
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
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
+
}
98
381
99
-
r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef)
382
+
gw := gzip.NewWriter(w)
383
+
defer gw.Close()
100
384
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
-
})
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
+
}
106
402
107
-
r.Route("/tree/{ref}", func(r chi.Router) {
108
-
r.Get("/", h.RepoIndex)
109
-
r.Get("/*", h.RepoTree)
110
-
})
403
+
func (h *Handle) Log(w http.ResponseWriter, r *http.Request) {
404
+
ref := chi.URLParam(r, "ref")
405
+
ref, _ = url.PathUnescape(ref)
111
406
112
-
r.Route("/blob/{ref}", func(r chi.Router) {
113
-
r.Get("/*", h.Blob)
114
-
})
407
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
115
408
116
-
r.Route("/raw/{ref}", func(r chi.Router) {
117
-
r.Get("/*", h.BlobRaw)
118
-
})
409
+
l := h.l.With("handler", "Log", "ref", ref, "path", path)
119
410
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
-
})
411
+
gr, err := git.Open(path, ref)
412
+
if err != nil {
413
+
notFound(w)
414
+
return
415
+
}
134
416
135
-
// xrpc apis
136
-
r.Mount("/xrpc", h.XrpcRouter())
417
+
// Get page parameters
418
+
page := 1
419
+
pageSize := 30
137
420
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
-
})
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
+
}
149
426
150
-
r.Route("/member", func(r chi.Router) {
151
-
r.Use(h.VerifySignature)
152
-
r.Put("/add", h.AddMember)
153
-
})
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
+
}
154
432
155
-
// Socket that streams git oplogs
156
-
r.Get("/events", h.Events)
433
+
// convert to offset/limit
434
+
offset := (page - 1) * pageSize
435
+
limit := pageSize
157
436
158
-
// Initialize the knot with an owner and public key.
159
-
r.With(h.VerifySignature).Post("/init", h.Init)
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
+
}
160
443
161
-
// Health check. Used for two-way verification with appview.
162
-
r.With(h.VerifySignature).Get("/health", h.Health)
444
+
total := len(commits)
163
445
164
-
// All public keys on the knot.
165
-
r.Get("/keys", h.Keys)
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
+
}
166
455
167
-
return r, nil
456
+
writeJSON(w, resp)
168
457
}
169
458
170
-
func (h *Handle) XrpcRouter() http.Handler {
171
-
logger := tlog.New("knots")
459
+
func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) {
460
+
ref := chi.URLParam(r, "ref")
461
+
ref, _ = url.PathUnescape(ref)
172
462
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,
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
181
470
}
182
-
return xrpc.Router()
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)
183
485
}
184
486
185
-
// version is set during build time.
186
-
var version string
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")
187
490
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)
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
+
}
521
+
522
+
rtags = append(rtags, &tr)
523
+
}
524
+
525
+
resp := types.RepoTagsResponse{
526
+
Tags: rtags,
527
+
}
528
+
529
+
writeJSON(w, resp)
530
+
}
531
+
532
+
func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) {
533
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
534
+
535
+
gr, err := git.PlainOpen(path)
536
+
if err != nil {
537
+
notFound(w)
538
+
return
539
+
}
540
+
541
+
branches, _ := gr.Branches()
542
+
543
+
resp := types.RepoBranchesResponse{
544
+
Branches: branches,
545
+
}
546
+
547
+
writeJSON(w, resp)
548
+
}
549
+
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
+
}
562
+
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
568
+
}
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)
598
+
}
599
+
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())
193
609
return
194
610
}
195
611
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
-
}
612
+
data := make([]map[string]any, 0)
613
+
for _, key := range keys {
614
+
j := key.JSON()
615
+
data = append(data, j)
202
616
}
617
+
writeJSON(w, data)
618
+
return
203
619
204
-
if modVer == "" {
205
-
version = "unknown"
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)
624
+
return
206
625
}
626
+
627
+
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key))
628
+
if err != nil {
629
+
writeError(w, "invalid pubkey", http.StatusBadRequest)
630
+
}
631
+
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
636
+
}
637
+
638
+
w.WriteHeader(http.StatusNoContent)
639
+
return
640
+
}
641
+
}
642
+
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
207
732
}
208
733
209
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
210
-
fmt.Fprintf(w, "knotserver/%s", version)
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
+
})
211
1069
}
-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
-
}
+20
-35
knotserver/ingester.go
+20
-35
knotserver/ingester.go
···
8
8
"net/http"
9
9
"net/url"
10
10
"path/filepath"
11
-
"slices"
12
11
"strings"
13
12
14
13
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
74
73
}
75
74
l.Info("added member from firehose", "member", record.Subject)
76
75
77
-
if err := h.db.AddDid(did); err != nil {
76
+
if err := h.db.AddDid(record.Subject); err != nil {
78
77
l.Error("failed to add did", "error", err)
79
78
return fmt.Errorf("failed to add did: %w", err)
80
79
}
81
-
h.jc.AddDid(did)
80
+
h.jc.AddDid(record.Subject)
82
81
83
-
if err := h.fetchAndAddKeys(ctx, did); err != nil {
82
+
if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil {
84
83
return fmt.Errorf("failed to fetch and add keys: %w", err)
85
84
}
86
85
···
103
102
l = l.With("target_branch", record.TargetBranch)
104
103
105
104
if record.Source == nil {
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)
105
+
return fmt.Errorf("ignoring pull record: not a branch-based pull request")
109
106
}
110
107
111
108
if record.Source.Repo != nil {
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)
109
+
return fmt.Errorf("ignoring pull record: fork based pull")
127
110
}
128
111
129
112
repoAt, err := syntax.ParseATURI(record.TargetRepo)
130
113
if err != nil {
131
-
return err
114
+
return fmt.Errorf("failed to parse ATURI: %w", err)
132
115
}
133
116
134
117
// resolve this aturi to extract the repo record
···
144
127
145
128
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
146
129
if err != nil {
147
-
return err
130
+
return fmt.Errorf("failed to resolver repo: %w", err)
148
131
}
149
132
150
133
repo := resp.Value.Val.(*tangled.Repo)
151
134
152
135
if 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)
136
+
return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname)
156
137
}
157
138
158
139
didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name)
159
140
if err != nil {
160
-
return err
141
+
return fmt.Errorf("failed to construct relative repo path: %w", err)
161
142
}
162
143
163
144
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
164
145
if err != nil {
165
-
return err
146
+
return fmt.Errorf("failed to construct absolute repo path: %w", err)
166
147
}
167
148
168
149
gr, err := git.Open(repoPath, record.Source.Branch)
169
150
if err != nil {
170
-
return err
151
+
return fmt.Errorf("failed to open git repository: %w", err)
171
152
}
172
153
173
154
workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir)
174
155
if err != nil {
175
-
return err
156
+
return fmt.Errorf("failed to open workflow directory: %w", err)
176
157
}
177
158
178
159
var pipeline workflow.RawPipeline
···
215
196
cp := compiler.Compile(compiler.Parse(pipeline))
216
197
eventJson, err := json.Marshal(cp)
217
198
if err != nil {
218
-
return err
199
+
return fmt.Errorf("failed to marshal pipeline event: %w", err)
219
200
}
220
201
221
202
// do not run empty pipelines
···
274
255
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
275
256
276
257
// check perms for this user
277
-
if ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo); !ok || err != nil {
278
-
return fmt.Errorf("insufficient permissions: %w", err)
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)
279
264
}
280
265
281
266
if err := h.db.AddDid(subjectId.DID.String()); err != nil {
···
317
302
return fmt.Errorf("error reading response body: %w", err)
318
303
}
319
304
320
-
for _, key := range strings.Split(string(plaintext), "\n") {
305
+
for key := range strings.SplitSeq(string(plaintext), "\n") {
321
306
if key == "" {
322
307
continue
323
308
}
-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
-
}
+138
-1292
knotserver/routes.go
+138
-1292
knotserver/routes.go
···
1
1
package knotserver
2
2
3
3
import (
4
-
"compress/gzip"
5
4
"context"
6
-
"crypto/hmac"
7
-
"crypto/sha256"
8
-
"encoding/hex"
9
-
"encoding/json"
10
-
"errors"
11
5
"fmt"
12
-
"log"
6
+
"log/slog"
13
7
"net/http"
14
-
"net/url"
15
-
"os"
16
-
"path/filepath"
17
-
"strconv"
18
-
"strings"
19
-
"sync"
20
-
"time"
8
+
"runtime/debug"
21
9
22
-
securejoin "github.com/cyphar/filepath-securejoin"
23
-
"github.com/gliderlabs/ssh"
24
10
"github.com/go-chi/chi/v5"
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"
11
+
"tangled.sh/tangled.sh/core/idresolver"
12
+
"tangled.sh/tangled.sh/core/jetstream"
13
+
"tangled.sh/tangled.sh/core/knotserver/config"
29
14
"tangled.sh/tangled.sh/core/knotserver/db"
30
-
"tangled.sh/tangled.sh/core/knotserver/git"
31
-
"tangled.sh/tangled.sh/core/patchutil"
15
+
"tangled.sh/tangled.sh/core/knotserver/xrpc"
16
+
tlog "tangled.sh/tangled.sh/core/log"
17
+
"tangled.sh/tangled.sh/core/notifier"
32
18
"tangled.sh/tangled.sh/core/rbac"
33
-
"tangled.sh/tangled.sh/core/types"
19
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
34
20
)
35
21
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"))
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
38
30
}
39
31
40
-
func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) {
41
-
w.Header().Set("Content-Type", "application/json")
42
-
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
-
}
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()
51
34
52
-
jsonData, err := json.Marshal(capabilities)
53
-
if err != nil {
54
-
http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError)
55
-
return
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(),
56
43
}
57
44
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)
45
+
err := e.AddKnot(rbac.ThisServer)
68
46
if err != nil {
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)
47
+
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
189
48
}
190
49
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,
50
+
// configure owner
51
+
if err = h.configureOwner(); err != nil {
52
+
return nil, err
222
53
}
54
+
h.l.Info("owner set", "did", h.c.Server.Owner)
55
+
h.jc.AddDid(h.c.Server.Owner)
223
56
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)
57
+
// configure known-dids in jetstream consumer
58
+
dids, err := h.db.GetAllDids()
237
59
if err != nil {
238
-
notFound(w)
239
-
return
60
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
240
61
}
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
62
+
for _, d := range dids {
63
+
jc.AddDid(d)
247
64
}
248
65
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)
66
+
err = h.jc.StartJetstream(ctx, h.processMessages)
270
67
if err != nil {
271
-
notFound(w)
272
-
return
68
+
return nil, fmt.Errorf("failed to start jetstream: %w", err)
273
69
}
274
70
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
-
}
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) {
281
80
282
-
mimeType := http.DetectContentType(contents)
81
+
r.Route("/languages", func(r chi.Router) {
82
+
r.Get("/", h.RepoLanguages)
83
+
r.Get("/{ref}", h.RepoLanguages)
84
+
})
283
85
284
-
// exception for svg
285
-
if filepath.Ext(treePath) == ".svg" {
286
-
mimeType = "image/svg+xml"
287
-
}
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
288
91
289
-
contentHash := sha256.Sum256(contents)
290
-
eTag := fmt.Sprintf("\"%x\"", contentHash)
92
+
r.Route("/tree/{ref}", func(r chi.Router) {
93
+
r.Get("/", h.RepoIndex)
94
+
r.Get("/*", h.RepoTree)
95
+
})
291
96
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)
97
+
r.Route("/blob/{ref}", func(r chi.Router) {
98
+
r.Get("/*", h.Blob)
99
+
})
300
100
301
-
case strings.HasPrefix(mimeType, "text/plain"):
302
-
w.Header().Set("Cache-Control", "public, no-cache")
101
+
r.Route("/raw/{ref}", func(r chi.Router) {
102
+
r.Get("/*", h.BlobRaw)
103
+
})
303
104
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
-
}
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
+
})
309
116
310
-
w.Header().Set("Content-Type", mimeType)
311
-
w.Write(contents)
312
-
}
117
+
// xrpc apis
118
+
r.Mount("/xrpc", h.XrpcRouter())
313
119
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)
120
+
// Socket that streams git oplogs
121
+
r.Get("/events", h.Events)
318
122
319
-
l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath)
123
+
// All public keys on the knot.
124
+
r.Get("/keys", h.Keys)
320
125
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),
350
-
}
351
-
352
-
h.showFile(resp, w, l)
126
+
return r, nil
353
127
}
354
128
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
365
-
}
366
-
367
-
ref := strings.TrimSuffix(file, ".tar.gz")
368
-
369
-
unescapedRef, err := url.PathUnescape(ref)
370
-
if err != nil {
371
-
notFound(w)
372
-
return
373
-
}
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
388
-
}
389
-
390
-
gw := gzip.NewWriter(w)
391
-
defer gw.Close()
392
-
393
-
prefix := fmt.Sprintf("%s-%s", name, safeRefFilename)
394
-
err = gr.WriteTar(gw, prefix)
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("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
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)
414
-
415
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
416
-
417
-
l := h.l.With("handler", "Log", "ref", ref, "path", path)
418
-
419
-
gr, err := git.Open(path, ref)
420
-
if err != nil {
421
-
notFound(w)
422
-
return
423
-
}
424
-
425
-
// Get page parameters
426
-
page := 1
427
-
pageSize := 30
428
-
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
-
}
434
-
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
-
}
440
-
441
-
// convert to offset/limit
442
-
offset := (page - 1) * pageSize
443
-
limit := pageSize
444
-
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
-
}
451
-
452
-
total := len(commits)
453
-
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
-
}
463
-
464
-
writeJSON(w, resp)
465
-
return
466
-
}
467
-
468
-
func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) {
469
-
ref := chi.URLParam(r, "ref")
470
-
ref, _ = url.PathUnescape(ref)
471
-
472
-
l := h.l.With("handler", "Diff", "ref", ref)
473
-
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,
491
-
}
492
-
493
-
writeJSON(w, resp)
494
-
return
495
-
}
496
-
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
-
}
512
-
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,
521
-
}
522
-
523
-
tr.Reference = types.Reference{
524
-
Name: tag.Name,
525
-
Hash: tag.Hash.String(),
526
-
}
527
-
528
-
if tag.Message != "" {
529
-
tr.Message = tag.Message
530
-
}
531
-
532
-
rtags = append(rtags, &tr)
533
-
}
534
-
535
-
resp := types.RepoTagsResponse{
536
-
Tags: rtags,
537
-
}
538
-
539
-
writeJSON(w, resp)
540
-
return
541
-
}
542
-
543
-
func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) {
544
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
545
-
546
-
gr, err := git.PlainOpen(path)
547
-
if err != nil {
548
-
notFound(w)
549
-
return
550
-
}
551
-
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)
570
-
if err != nil {
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
596
-
}
597
-
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
-
}
612
-
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
623
-
}
624
-
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))
641
-
if err != nil {
642
-
writeError(w, "invalid pubkey", http.StatusBadRequest)
643
-
}
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
668
-
}
669
-
670
-
if data.DefaultBranch == "" {
671
-
data.DefaultBranch = h.c.Repo.MainBranch
672
-
}
129
+
func (h *Handle) XrpcRouter() http.Handler {
130
+
logger := tlog.New("knots")
673
131
674
-
did := data.Did
675
-
name := data.Name
676
-
defaultBranch := data.DefaultBranch
132
+
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
677
133
678
-
if err := validateRepoName(name); err != nil {
679
-
l.Error("creating repo", "error", err.Error())
680
-
writeError(w, err.Error(), http.StatusBadRequest)
681
-
return
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,
682
143
}
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)
144
+
return xrpc.Router()
715
145
}
716
146
717
-
func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) {
718
-
l := h.l.With("handler", "RepoForkAheadBehind")
147
+
// version is set during build time.
148
+
var version string
719
149
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)
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)
780
155
return
781
156
}
782
157
783
-
if isAncestor {
784
-
status = types.FastForwardable
785
-
} else {
786
-
status = types.Conflict
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
+
}
787
164
}
788
-
}
789
165
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())
166
+
if modVer == "" {
167
+
version = "unknown"
1017
168
}
1018
-
return
1019
169
}
1020
170
1021
-
w.WriteHeader(http.StatusOK)
171
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
172
+
fmt.Fprintf(w, "knotserver/%s", version)
1022
173
}
1023
174
1024
-
func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) {
1025
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
175
+
func (h *Handle) configureOwner() error {
176
+
cfgOwner := h.c.Server.Owner
1026
177
1027
-
var data struct {
1028
-
Patch string `json:"patch"`
1029
-
Branch string `json:"branch"`
1030
-
}
178
+
rbacDomain := "thisserver"
1031
179
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)
180
+
existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain)
1041
181
if err != nil {
1042
-
notFound(w)
1043
-
return
182
+
return err
1044
183
}
1045
184
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
-
}
185
+
switch len(existing) {
186
+
case 0:
187
+
// no owner configured, continue
188
+
case 1:
189
+
// find existing owner
190
+
existingOwner := existing[0]
1054
191
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
-
}
192
+
// no ownership change, this is okay
193
+
if existingOwner == h.c.Server.Owner {
194
+
break
1063
195
}
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
196
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")
197
+
// remove existing owner
198
+
err = h.e.RemoveKnotOwner(rbacDomain, existingOwner)
199
+
if err != nil {
200
+
return nil
1351
201
}
202
+
default:
203
+
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath)
1352
204
}
1353
205
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
206
+
return h.e.AddKnotOwner(rbacDomain, cfgOwner)
1361
207
}
+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
-
}
+12
-10
knotserver/xrpc/set_default_branch.go
+12
-10
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"
15
17
)
16
18
17
19
const ActorDid string = "ActorDid"
18
20
19
21
func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
20
22
l := x.Logger
21
-
fail := func(e XrpcError) {
23
+
fail := func(e xrpcerr.XrpcError) {
22
24
l.Error("failed", "kind", e.Tag, "error", e.Message)
23
25
writeError(w, e, http.StatusBadRequest)
24
26
}
25
27
26
28
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
27
29
if !ok {
28
-
fail(MissingActorDidError)
30
+
fail(xrpcerr.MissingActorDidError)
29
31
return
30
32
}
31
33
32
34
var data tangled.RepoSetDefaultBranch_Input
33
35
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
34
-
fail(GenericError(err))
36
+
fail(xrpcerr.GenericError(err))
35
37
return
36
38
}
37
39
38
40
// unfortunately we have to resolve repo-at here
39
41
repoAt, err := syntax.ParseATURI(data.Repo)
40
42
if err != nil {
41
-
fail(InvalidRepoError(data.Repo))
43
+
fail(xrpcerr.InvalidRepoError(data.Repo))
42
44
return
43
45
}
44
46
45
47
// resolve this aturi to extract the repo record
46
48
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
47
49
if err != nil || ident.Handle.IsInvalidHandle() {
48
-
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
50
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
49
51
return
50
52
}
51
53
52
54
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
53
55
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
54
56
if err != nil {
55
-
fail(GenericError(err))
57
+
fail(xrpcerr.GenericError(err))
56
58
return
57
59
}
58
60
59
61
repo := resp.Value.Val.(*tangled.Repo)
60
62
didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name)
61
63
if err != nil {
62
-
fail(GenericError(err))
64
+
fail(xrpcerr.GenericError(err))
63
65
return
64
66
}
65
67
66
68
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
67
69
l.Error("insufficent permissions", "did", actorDid.String())
68
-
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
70
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
69
71
return
70
72
}
71
73
72
74
path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath)
73
75
gr, err := git.PlainOpen(path)
74
76
if err != nil {
75
-
fail(InvalidRepoError(data.Repo))
77
+
fail(xrpcerr.GenericError(err))
76
78
return
77
79
}
78
80
79
81
err = gr.SetDefaultBranch(data.DefaultBranch)
80
82
if err != nil {
81
83
l.Error("setting default branch", "error", err.Error())
82
-
writeError(w, GitError(err), http.StatusInternalServerError)
84
+
writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError)
83
85
return
84
86
}
85
87
+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
+
}
+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
-
secretFile = mkOption {
97
-
type = lib.types.path;
98
-
example = "KNOT_SERVER_SECRET=<hash>";
99
-
description = "File containing secret key provided by appview (required)";
96
+
owner = mkOption {
97
+
type = types.str;
98
+
example = "did:plc:qfpnj4og54vl56wngdriaxug";
99
+
description = "DID of owner (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}"
202
203
];
203
-
EnvironmentFile = cfg.server.secretFile;
204
204
ExecStart = "${cfg.package}/bin/knot server";
205
205
Restart = "always";
206
206
};
+2
-1
nix/vm.nix
+2
-1
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";
73
74
services.getty.autologinUser = "root";
74
75
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
75
76
services.tangled-knot = {
76
77
enable = true;
77
78
motd = "Welcome to the development knot!\n";
78
79
server = {
79
-
secretFile = builtins.toFile "knot-secret" ("KNOT_SERVER_SECRET=" + (envVar "TANGLED_VM_KNOT_SECRET"));
80
+
owner = envVar "TANGLED_VM_KNOT_OWNER";
80
81
hostname = "localhost:6000";
81
82
listenAddr = "0.0.0.0:6000";
82
83
};
+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
+
103
108
func (e *Enforcer) GetKnotsForUser(did string) ([]string, error) {
104
109
keepFunc := isNotSpindle
105
110
stripFunc := unSpindle
···
270
275
271
276
func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) {
272
277
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")
273
286
}
274
287
275
288
func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
+7
xrpc/errors/errors.go
+7
xrpc/errors/errors.go
···
86
86
)
87
87
}
88
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
+
89
96
func GenericError(err error) XrpcError {
90
97
return NewXrpcError(
91
98
WithTag("Generic"),