+16
-73
api/tangled/cbor_gen.go
+16
-73
api/tangled/cbor_gen.go
···
5898
5898
}
5899
5899
5900
5900
cw := cbg.NewCborWriter(w)
5901
-
fieldCount := 6
5902
-
5903
-
if t.Owner == nil {
5904
-
fieldCount--
5905
-
}
5901
+
fieldCount := 5
5906
5902
5907
-
if t.Repo == nil {
5903
+
if t.ReplyTo == nil {
5908
5904
fieldCount--
5909
5905
}
5910
5906
···
5935
5931
return err
5936
5932
}
5937
5933
5938
-
// t.Repo (string) (string)
5939
-
if t.Repo != nil {
5940
-
5941
-
if len("repo") > 1000000 {
5942
-
return xerrors.Errorf("Value in field \"repo\" was too long")
5943
-
}
5944
-
5945
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
5946
-
return err
5947
-
}
5948
-
if _, err := cw.WriteString(string("repo")); err != nil {
5949
-
return err
5950
-
}
5951
-
5952
-
if t.Repo == nil {
5953
-
if _, err := cw.Write(cbg.CborNull); err != nil {
5954
-
return err
5955
-
}
5956
-
} else {
5957
-
if len(*t.Repo) > 1000000 {
5958
-
return xerrors.Errorf("Value in field t.Repo was too long")
5959
-
}
5960
-
5961
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil {
5962
-
return err
5963
-
}
5964
-
if _, err := cw.WriteString(string(*t.Repo)); err != nil {
5965
-
return err
5966
-
}
5967
-
}
5968
-
}
5969
-
5970
5934
// t.LexiconTypeID (string) (string)
5971
5935
if len("$type") > 1000000 {
5972
5936
return xerrors.Errorf("Value in field \"$type\" was too long")
···
6009
5973
return err
6010
5974
}
6011
5975
6012
-
// t.Owner (string) (string)
6013
-
if t.Owner != nil {
5976
+
// t.ReplyTo (string) (string)
5977
+
if t.ReplyTo != nil {
6014
5978
6015
-
if len("owner") > 1000000 {
6016
-
return xerrors.Errorf("Value in field \"owner\" was too long")
5979
+
if len("replyTo") > 1000000 {
5980
+
return xerrors.Errorf("Value in field \"replyTo\" was too long")
6017
5981
}
6018
5982
6019
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
5983
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil {
6020
5984
return err
6021
5985
}
6022
-
if _, err := cw.WriteString(string("owner")); err != nil {
5986
+
if _, err := cw.WriteString(string("replyTo")); err != nil {
6023
5987
return err
6024
5988
}
6025
5989
6026
-
if t.Owner == nil {
5990
+
if t.ReplyTo == nil {
6027
5991
if _, err := cw.Write(cbg.CborNull); err != nil {
6028
5992
return err
6029
5993
}
6030
5994
} else {
6031
-
if len(*t.Owner) > 1000000 {
6032
-
return xerrors.Errorf("Value in field t.Owner was too long")
5995
+
if len(*t.ReplyTo) > 1000000 {
5996
+
return xerrors.Errorf("Value in field t.ReplyTo was too long")
6033
5997
}
6034
5998
6035
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil {
5999
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil {
6036
6000
return err
6037
6001
}
6038
-
if _, err := cw.WriteString(string(*t.Owner)); err != nil {
6002
+
if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil {
6039
6003
return err
6040
6004
}
6041
6005
}
···
6118
6082
6119
6083
t.Body = string(sval)
6120
6084
}
6121
-
// t.Repo (string) (string)
6122
-
case "repo":
6123
-
6124
-
{
6125
-
b, err := cr.ReadByte()
6126
-
if err != nil {
6127
-
return err
6128
-
}
6129
-
if b != cbg.CborNull[0] {
6130
-
if err := cr.UnreadByte(); err != nil {
6131
-
return err
6132
-
}
6133
-
6134
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6135
-
if err != nil {
6136
-
return err
6137
-
}
6138
-
6139
-
t.Repo = (*string)(&sval)
6140
-
}
6141
-
}
6142
6085
// t.LexiconTypeID (string) (string)
6143
6086
case "$type":
6144
6087
···
6161
6104
6162
6105
t.Issue = string(sval)
6163
6106
}
6164
-
// t.Owner (string) (string)
6165
-
case "owner":
6107
+
// t.ReplyTo (string) (string)
6108
+
case "replyTo":
6166
6109
6167
6110
{
6168
6111
b, err := cr.ReadByte()
···
6179
6122
return err
6180
6123
}
6181
6124
6182
-
t.Owner = (*string)(&sval)
6125
+
t.ReplyTo = (*string)(&sval)
6183
6126
}
6184
6127
}
6185
6128
// t.CreatedAt (string) (string)
+1
-2
api/tangled/issuecomment.go
+1
-2
api/tangled/issuecomment.go
···
21
21
Body string `json:"body" cborgen:"body"`
22
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
23
Issue string `json:"issue" cborgen:"issue"`
24
-
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
25
-
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
24
+
ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"`
26
25
}
+53
api/tangled/knotlistKeys.go
+53
api/tangled/knotlistKeys.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.knot.listKeys
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
KnotListKeysNSID = "sh.tangled.knot.listKeys"
15
+
)
16
+
17
+
// KnotListKeys_Output is the output of a sh.tangled.knot.listKeys call.
18
+
type KnotListKeys_Output struct {
19
+
// cursor: Pagination cursor for next page
20
+
Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"`
21
+
Keys []*KnotListKeys_PublicKey `json:"keys" cborgen:"keys"`
22
+
}
23
+
24
+
// KnotListKeys_PublicKey is a "publicKey" in the sh.tangled.knot.listKeys schema.
25
+
type KnotListKeys_PublicKey struct {
26
+
// createdAt: Key upload timestamp
27
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
28
+
// did: DID associated with the public key
29
+
Did string `json:"did" cborgen:"did"`
30
+
// key: Public key contents
31
+
Key string `json:"key" cborgen:"key"`
32
+
}
33
+
34
+
// KnotListKeys calls the XRPC method "sh.tangled.knot.listKeys".
35
+
//
36
+
// cursor: Pagination cursor
37
+
// limit: Maximum number of keys to return
38
+
func KnotListKeys(ctx context.Context, c util.LexClient, cursor string, limit int64) (*KnotListKeys_Output, error) {
39
+
var out KnotListKeys_Output
40
+
41
+
params := map[string]interface{}{}
42
+
if cursor != "" {
43
+
params["cursor"] = cursor
44
+
}
45
+
if limit != 0 {
46
+
params["limit"] = limit
47
+
}
48
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.listKeys", params, nil, &out); err != nil {
49
+
return nil, err
50
+
}
51
+
52
+
return &out, nil
53
+
}
+30
api/tangled/knotversion.go
+30
api/tangled/knotversion.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.knot.version
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
KnotVersionNSID = "sh.tangled.knot.version"
15
+
)
16
+
17
+
// KnotVersion_Output is the output of a sh.tangled.knot.version call.
18
+
type KnotVersion_Output struct {
19
+
Version string `json:"version" cborgen:"version"`
20
+
}
21
+
22
+
// KnotVersion calls the XRPC method "sh.tangled.knot.version".
23
+
func KnotVersion(ctx context.Context, c util.LexClient) (*KnotVersion_Output, error) {
24
+
var out KnotVersion_Output
25
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.version", nil, nil, &out); err != nil {
26
+
return nil, err
27
+
}
28
+
29
+
return &out, nil
30
+
}
+41
api/tangled/repoarchive.go
+41
api/tangled/repoarchive.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.archive
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoArchiveNSID = "sh.tangled.repo.archive"
16
+
)
17
+
18
+
// RepoArchive calls the XRPC method "sh.tangled.repo.archive".
19
+
//
20
+
// format: Archive format
21
+
// prefix: Prefix for files in the archive
22
+
// ref: Git reference (branch, tag, or commit SHA)
23
+
// repo: Repository identifier in format 'did:plc:.../repoName'
24
+
func RepoArchive(ctx context.Context, c util.LexClient, format string, prefix string, ref string, repo string) ([]byte, error) {
25
+
buf := new(bytes.Buffer)
26
+
27
+
params := map[string]interface{}{}
28
+
if format != "" {
29
+
params["format"] = format
30
+
}
31
+
if prefix != "" {
32
+
params["prefix"] = prefix
33
+
}
34
+
params["ref"] = ref
35
+
params["repo"] = repo
36
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.archive", params, nil, buf); err != nil {
37
+
return nil, err
38
+
}
39
+
40
+
return buf.Bytes(), nil
41
+
}
+80
api/tangled/repoblob.go
+80
api/tangled/repoblob.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.blob
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoBlobNSID = "sh.tangled.repo.blob"
15
+
)
16
+
17
+
// RepoBlob_LastCommit is a "lastCommit" in the sh.tangled.repo.blob schema.
18
+
type RepoBlob_LastCommit struct {
19
+
Author *RepoBlob_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
20
+
// hash: Commit hash
21
+
Hash string `json:"hash" cborgen:"hash"`
22
+
// message: Commit message
23
+
Message string `json:"message" cborgen:"message"`
24
+
// shortHash: Short commit hash
25
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
26
+
// when: Commit timestamp
27
+
When string `json:"when" cborgen:"when"`
28
+
}
29
+
30
+
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
31
+
type RepoBlob_Output struct {
32
+
// content: File content (base64 encoded for binary files)
33
+
Content string `json:"content" cborgen:"content"`
34
+
// encoding: Content encoding
35
+
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
36
+
// isBinary: Whether the file is binary
37
+
IsBinary *bool `json:"isBinary,omitempty" cborgen:"isBinary,omitempty"`
38
+
LastCommit *RepoBlob_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"`
39
+
// mimeType: MIME type of the file
40
+
MimeType *string `json:"mimeType,omitempty" cborgen:"mimeType,omitempty"`
41
+
// path: The file path
42
+
Path string `json:"path" cborgen:"path"`
43
+
// ref: The git reference used
44
+
Ref string `json:"ref" cborgen:"ref"`
45
+
// size: File size in bytes
46
+
Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"`
47
+
}
48
+
49
+
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
50
+
type RepoBlob_Signature struct {
51
+
// email: Author email
52
+
Email string `json:"email" cborgen:"email"`
53
+
// name: Author name
54
+
Name string `json:"name" cborgen:"name"`
55
+
// when: Author timestamp
56
+
When string `json:"when" cborgen:"when"`
57
+
}
58
+
59
+
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
60
+
//
61
+
// path: Path to the file within the repository
62
+
// raw: Return raw file content instead of JSON response
63
+
// ref: Git reference (branch, tag, or commit SHA)
64
+
// repo: Repository identifier in format 'did:plc:.../repoName'
65
+
func RepoBlob(ctx context.Context, c util.LexClient, path string, raw bool, ref string, repo string) (*RepoBlob_Output, error) {
66
+
var out RepoBlob_Output
67
+
68
+
params := map[string]interface{}{}
69
+
params["path"] = path
70
+
if raw {
71
+
params["raw"] = raw
72
+
}
73
+
params["ref"] = ref
74
+
params["repo"] = repo
75
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.blob", params, nil, &out); err != nil {
76
+
return nil, err
77
+
}
78
+
79
+
return &out, nil
80
+
}
+59
api/tangled/repobranch.go
+59
api/tangled/repobranch.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.branch
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoBranchNSID = "sh.tangled.repo.branch"
15
+
)
16
+
17
+
// RepoBranch_Output is the output of a sh.tangled.repo.branch call.
18
+
type RepoBranch_Output struct {
19
+
Author *RepoBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
20
+
// hash: Latest commit hash on this branch
21
+
Hash string `json:"hash" cborgen:"hash"`
22
+
// isDefault: Whether this is the default branch
23
+
IsDefault *bool `json:"isDefault,omitempty" cborgen:"isDefault,omitempty"`
24
+
// message: Latest commit message
25
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
26
+
// name: Branch name
27
+
Name string `json:"name" cborgen:"name"`
28
+
// shortHash: Short commit hash
29
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
30
+
// when: Timestamp of latest commit
31
+
When string `json:"when" cborgen:"when"`
32
+
}
33
+
34
+
// RepoBranch_Signature is a "signature" in the sh.tangled.repo.branch schema.
35
+
type RepoBranch_Signature struct {
36
+
// email: Author email
37
+
Email string `json:"email" cborgen:"email"`
38
+
// name: Author name
39
+
Name string `json:"name" cborgen:"name"`
40
+
// when: Author timestamp
41
+
When string `json:"when" cborgen:"when"`
42
+
}
43
+
44
+
// RepoBranch calls the XRPC method "sh.tangled.repo.branch".
45
+
//
46
+
// name: Branch name to get information for
47
+
// repo: Repository identifier in format 'did:plc:.../repoName'
48
+
func RepoBranch(ctx context.Context, c util.LexClient, name string, repo string) (*RepoBranch_Output, error) {
49
+
var out RepoBranch_Output
50
+
51
+
params := map[string]interface{}{}
52
+
params["name"] = name
53
+
params["repo"] = repo
54
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branch", params, nil, &out); err != nil {
55
+
return nil, err
56
+
}
57
+
58
+
return &out, nil
59
+
}
+39
api/tangled/repobranches.go
+39
api/tangled/repobranches.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.branches
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoBranchesNSID = "sh.tangled.repo.branches"
16
+
)
17
+
18
+
// RepoBranches calls the XRPC method "sh.tangled.repo.branches".
19
+
//
20
+
// cursor: Pagination cursor
21
+
// limit: Maximum number of branches to return
22
+
// repo: Repository identifier in format 'did:plc:.../repoName'
23
+
func RepoBranches(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) {
24
+
buf := new(bytes.Buffer)
25
+
26
+
params := map[string]interface{}{}
27
+
if cursor != "" {
28
+
params["cursor"] = cursor
29
+
}
30
+
if limit != 0 {
31
+
params["limit"] = limit
32
+
}
33
+
params["repo"] = repo
34
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branches", params, nil, buf); err != nil {
35
+
return nil, err
36
+
}
37
+
38
+
return buf.Bytes(), nil
39
+
}
+35
api/tangled/repocompare.go
+35
api/tangled/repocompare.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.compare
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoCompareNSID = "sh.tangled.repo.compare"
16
+
)
17
+
18
+
// RepoCompare calls the XRPC method "sh.tangled.repo.compare".
19
+
//
20
+
// repo: Repository identifier in format 'did:plc:.../repoName'
21
+
// rev1: First revision (commit, branch, or tag)
22
+
// rev2: Second revision (commit, branch, or tag)
23
+
func RepoCompare(ctx context.Context, c util.LexClient, repo string, rev1 string, rev2 string) ([]byte, error) {
24
+
buf := new(bytes.Buffer)
25
+
26
+
params := map[string]interface{}{}
27
+
params["repo"] = repo
28
+
params["rev1"] = rev1
29
+
params["rev2"] = rev2
30
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.compare", params, nil, buf); err != nil {
31
+
return nil, err
32
+
}
33
+
34
+
return buf.Bytes(), nil
35
+
}
+33
api/tangled/repodiff.go
+33
api/tangled/repodiff.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.diff
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoDiffNSID = "sh.tangled.repo.diff"
16
+
)
17
+
18
+
// RepoDiff calls the XRPC method "sh.tangled.repo.diff".
19
+
//
20
+
// ref: Git reference (branch, tag, or commit SHA)
21
+
// repo: Repository identifier in format 'did:plc:.../repoName'
22
+
func RepoDiff(ctx context.Context, c util.LexClient, ref string, repo string) ([]byte, error) {
23
+
buf := new(bytes.Buffer)
24
+
25
+
params := map[string]interface{}{}
26
+
params["ref"] = ref
27
+
params["repo"] = repo
28
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.diff", params, nil, buf); err != nil {
29
+
return nil, err
30
+
}
31
+
32
+
return buf.Bytes(), nil
33
+
}
+55
api/tangled/repogetDefaultBranch.go
+55
api/tangled/repogetDefaultBranch.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.getDefaultBranch
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoGetDefaultBranchNSID = "sh.tangled.repo.getDefaultBranch"
15
+
)
16
+
17
+
// RepoGetDefaultBranch_Output is the output of a sh.tangled.repo.getDefaultBranch call.
18
+
type RepoGetDefaultBranch_Output struct {
19
+
Author *RepoGetDefaultBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
20
+
// hash: Latest commit hash on default branch
21
+
Hash string `json:"hash" cborgen:"hash"`
22
+
// message: Latest commit message
23
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
24
+
// name: Default branch name
25
+
Name string `json:"name" cborgen:"name"`
26
+
// shortHash: Short commit hash
27
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
28
+
// when: Timestamp of latest commit
29
+
When string `json:"when" cborgen:"when"`
30
+
}
31
+
32
+
// RepoGetDefaultBranch_Signature is a "signature" in the sh.tangled.repo.getDefaultBranch schema.
33
+
type RepoGetDefaultBranch_Signature struct {
34
+
// email: Author email
35
+
Email string `json:"email" cborgen:"email"`
36
+
// name: Author name
37
+
Name string `json:"name" cborgen:"name"`
38
+
// when: Author timestamp
39
+
When string `json:"when" cborgen:"when"`
40
+
}
41
+
42
+
// RepoGetDefaultBranch calls the XRPC method "sh.tangled.repo.getDefaultBranch".
43
+
//
44
+
// repo: Repository identifier in format 'did:plc:.../repoName'
45
+
func RepoGetDefaultBranch(ctx context.Context, c util.LexClient, repo string) (*RepoGetDefaultBranch_Output, error) {
46
+
var out RepoGetDefaultBranch_Output
47
+
48
+
params := map[string]interface{}{}
49
+
params["repo"] = repo
50
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.getDefaultBranch", params, nil, &out); err != nil {
51
+
return nil, err
52
+
}
53
+
54
+
return &out, nil
55
+
}
+61
api/tangled/repolanguages.go
+61
api/tangled/repolanguages.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.languages
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoLanguagesNSID = "sh.tangled.repo.languages"
15
+
)
16
+
17
+
// RepoLanguages_Language is a "language" in the sh.tangled.repo.languages schema.
18
+
type RepoLanguages_Language struct {
19
+
// color: Hex color code for this language
20
+
Color *string `json:"color,omitempty" cborgen:"color,omitempty"`
21
+
// extensions: File extensions associated with this language
22
+
Extensions []string `json:"extensions,omitempty" cborgen:"extensions,omitempty"`
23
+
// fileCount: Number of files in this language
24
+
FileCount *int64 `json:"fileCount,omitempty" cborgen:"fileCount,omitempty"`
25
+
// name: Programming language name
26
+
Name string `json:"name" cborgen:"name"`
27
+
// percentage: Percentage of total codebase (0-100)
28
+
Percentage int64 `json:"percentage" cborgen:"percentage"`
29
+
// size: Total size of files in this language (bytes)
30
+
Size int64 `json:"size" cborgen:"size"`
31
+
}
32
+
33
+
// RepoLanguages_Output is the output of a sh.tangled.repo.languages call.
34
+
type RepoLanguages_Output struct {
35
+
Languages []*RepoLanguages_Language `json:"languages" cborgen:"languages"`
36
+
// ref: The git reference used
37
+
Ref string `json:"ref" cborgen:"ref"`
38
+
// totalFiles: Total number of files analyzed
39
+
TotalFiles *int64 `json:"totalFiles,omitempty" cborgen:"totalFiles,omitempty"`
40
+
// totalSize: Total size of all analyzed files in bytes
41
+
TotalSize *int64 `json:"totalSize,omitempty" cborgen:"totalSize,omitempty"`
42
+
}
43
+
44
+
// RepoLanguages calls the XRPC method "sh.tangled.repo.languages".
45
+
//
46
+
// ref: Git reference (branch, tag, or commit SHA)
47
+
// repo: Repository identifier in format 'did:plc:.../repoName'
48
+
func RepoLanguages(ctx context.Context, c util.LexClient, ref string, repo string) (*RepoLanguages_Output, error) {
49
+
var out RepoLanguages_Output
50
+
51
+
params := map[string]interface{}{}
52
+
if ref != "" {
53
+
params["ref"] = ref
54
+
}
55
+
params["repo"] = repo
56
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.languages", params, nil, &out); err != nil {
57
+
return nil, err
58
+
}
59
+
60
+
return &out, nil
61
+
}
+45
api/tangled/repolog.go
+45
api/tangled/repolog.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.log
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoLogNSID = "sh.tangled.repo.log"
16
+
)
17
+
18
+
// RepoLog calls the XRPC method "sh.tangled.repo.log".
19
+
//
20
+
// cursor: Pagination cursor (commit SHA)
21
+
// limit: Maximum number of commits to return
22
+
// path: Path to filter commits by
23
+
// ref: Git reference (branch, tag, or commit SHA)
24
+
// repo: Repository identifier in format 'did:plc:.../repoName'
25
+
func RepoLog(ctx context.Context, c util.LexClient, cursor string, limit int64, path string, ref string, repo string) ([]byte, error) {
26
+
buf := new(bytes.Buffer)
27
+
28
+
params := map[string]interface{}{}
29
+
if cursor != "" {
30
+
params["cursor"] = cursor
31
+
}
32
+
if limit != 0 {
33
+
params["limit"] = limit
34
+
}
35
+
if path != "" {
36
+
params["path"] = path
37
+
}
38
+
params["ref"] = ref
39
+
params["repo"] = repo
40
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.log", params, nil, buf); err != nil {
41
+
return nil, err
42
+
}
43
+
44
+
return buf.Bytes(), nil
45
+
}
+72
api/tangled/repotree.go
+72
api/tangled/repotree.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.tree
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoTreeNSID = "sh.tangled.repo.tree"
15
+
)
16
+
17
+
// RepoTree_LastCommit is a "lastCommit" in the sh.tangled.repo.tree schema.
18
+
type RepoTree_LastCommit struct {
19
+
// hash: Commit hash
20
+
Hash string `json:"hash" cborgen:"hash"`
21
+
// message: Commit message
22
+
Message string `json:"message" cborgen:"message"`
23
+
// when: Commit timestamp
24
+
When string `json:"when" cborgen:"when"`
25
+
}
26
+
27
+
// RepoTree_Output is the output of a sh.tangled.repo.tree call.
28
+
type RepoTree_Output struct {
29
+
// dotdot: Parent directory path
30
+
Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"`
31
+
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
32
+
// parent: The parent path in the tree
33
+
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
34
+
// ref: The git reference used
35
+
Ref string `json:"ref" cborgen:"ref"`
36
+
}
37
+
38
+
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
39
+
type RepoTree_TreeEntry struct {
40
+
// is_file: Whether this entry is a file
41
+
Is_file bool `json:"is_file" cborgen:"is_file"`
42
+
// is_subtree: Whether this entry is a directory/subtree
43
+
Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"`
44
+
Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"`
45
+
// mode: File mode
46
+
Mode string `json:"mode" cborgen:"mode"`
47
+
// name: Relative file or directory name
48
+
Name string `json:"name" cborgen:"name"`
49
+
// size: File size in bytes
50
+
Size int64 `json:"size" cborgen:"size"`
51
+
}
52
+
53
+
// RepoTree calls the XRPC method "sh.tangled.repo.tree".
54
+
//
55
+
// path: Path within the repository tree
56
+
// ref: Git reference (branch, tag, or commit SHA)
57
+
// repo: Repository identifier in format 'did:plc:.../repoName'
58
+
func RepoTree(ctx context.Context, c util.LexClient, path string, ref string, repo string) (*RepoTree_Output, error) {
59
+
var out RepoTree_Output
60
+
61
+
params := map[string]interface{}{}
62
+
if path != "" {
63
+
params["path"] = path
64
+
}
65
+
params["ref"] = ref
66
+
params["repo"] = repo
67
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tree", params, nil, &out); err != nil {
68
+
return nil, err
69
+
}
70
+
71
+
return &out, nil
72
+
}
+30
api/tangled/tangledowner.go
+30
api/tangled/tangledowner.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.owner
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
OwnerNSID = "sh.tangled.owner"
15
+
)
16
+
17
+
// Owner_Output is the output of a sh.tangled.owner call.
18
+
type Owner_Output struct {
19
+
Owner string `json:"owner" cborgen:"owner"`
20
+
}
21
+
22
+
// Owner calls the XRPC method "sh.tangled.owner".
23
+
func Owner(ctx context.Context, c util.LexClient) (*Owner_Output, error) {
24
+
var out Owner_Output
25
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.owner", nil, nil, &out); err != nil {
26
+
return nil, err
27
+
}
28
+
29
+
return &out, nil
30
+
}
+169
appview/db/db.go
+169
appview/db/db.go
···
703
703
return err
704
704
})
705
705
706
+
// repurpose the read-only column to "needs-upgrade"
707
+
runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
708
+
_, err := tx.Exec(`
709
+
alter table registrations rename column read_only to needs_upgrade;
710
+
`)
711
+
return err
712
+
})
713
+
714
+
// require all knots to upgrade after the release of total xrpc
715
+
runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
716
+
_, err := tx.Exec(`
717
+
update registrations set needs_upgrade = 1;
718
+
`)
719
+
return err
720
+
})
721
+
722
+
// require all knots to upgrade after the release of total xrpc
723
+
runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
724
+
_, err := tx.Exec(`
725
+
alter table spindles add column needs_upgrade integer not null default 0;
726
+
`)
727
+
if err != nil {
728
+
return err
729
+
}
730
+
731
+
_, err = tx.Exec(`
732
+
update spindles set needs_upgrade = 1;
733
+
`)
734
+
return err
735
+
})
736
+
737
+
// remove issue_at from issues and replace with generated column
738
+
//
739
+
// this requires a full table recreation because stored columns
740
+
// cannot be added via alter
741
+
//
742
+
// couple other changes:
743
+
// - columns renamed to be more consistent
744
+
// - adds edited and deleted fields
745
+
//
746
+
// disable foreign-keys for the next migration
747
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
748
+
runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
749
+
_, err := tx.Exec(`
750
+
create table if not exists issues_new (
751
+
-- identifiers
752
+
id integer primary key autoincrement,
753
+
did text not null,
754
+
rkey text not null,
755
+
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue' || '/' || rkey) stored,
756
+
757
+
-- at identifiers
758
+
repo_at text not null,
759
+
760
+
-- content
761
+
issue_id integer not null,
762
+
title text not null,
763
+
body text not null,
764
+
open integer not null default 1,
765
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
766
+
edited text, -- timestamp
767
+
deleted text, -- timestamp
768
+
769
+
unique(did, rkey),
770
+
unique(repo_at, issue_id),
771
+
unique(at_uri),
772
+
foreign key (repo_at) references repos(at_uri) on delete cascade
773
+
);
774
+
`)
775
+
if err != nil {
776
+
return err
777
+
}
778
+
779
+
// transfer data
780
+
_, err = tx.Exec(`
781
+
insert into issues_new (id, did, rkey, repo_at, issue_id, title, body, open, created)
782
+
select
783
+
i.id,
784
+
i.owner_did,
785
+
i.rkey,
786
+
i.repo_at,
787
+
i.issue_id,
788
+
i.title,
789
+
i.body,
790
+
i.open,
791
+
i.created
792
+
from issues i;
793
+
`)
794
+
if err != nil {
795
+
return err
796
+
}
797
+
798
+
// drop old table
799
+
_, err = tx.Exec(`drop table issues`)
800
+
if err != nil {
801
+
return err
802
+
}
803
+
804
+
// rename new table
805
+
_, err = tx.Exec(`alter table issues_new rename to issues`)
806
+
return err
807
+
})
808
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
809
+
810
+
// - renames the comments table to 'issue_comments'
811
+
// - rework issue comments to update constraints:
812
+
// * unique(did, rkey)
813
+
// * remove comment-id and just use the global ID
814
+
// * foreign key (repo_at, issue_id)
815
+
// - new columns
816
+
// * column "reply_to" which can be any other comment
817
+
// * column "at-uri" which is a generated column
818
+
runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error {
819
+
_, err := tx.Exec(`
820
+
create table if not exists issue_comments (
821
+
-- identifiers
822
+
id integer primary key autoincrement,
823
+
did text not null,
824
+
rkey text,
825
+
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue.comment' || '/' || rkey) stored,
826
+
827
+
-- at identifiers
828
+
issue_at text not null,
829
+
reply_to text, -- at_uri of parent comment
830
+
831
+
-- content
832
+
body text not null,
833
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
834
+
edited text,
835
+
deleted text,
836
+
837
+
-- constraints
838
+
unique(did, rkey),
839
+
unique(at_uri),
840
+
foreign key (issue_at) references issues(at_uri) on delete cascade
841
+
);
842
+
`)
843
+
if err != nil {
844
+
return err
845
+
}
846
+
847
+
// transfer data
848
+
_, err = tx.Exec(`
849
+
insert into issue_comments (id, did, rkey, issue_at, body, created, edited, deleted)
850
+
select
851
+
c.id,
852
+
c.owner_did,
853
+
c.rkey,
854
+
i.at_uri, -- get at_uri from issues table
855
+
c.body,
856
+
c.created,
857
+
c.edited,
858
+
c.deleted
859
+
from comments c
860
+
join issues i on c.repo_at = i.repo_at and c.issue_id = i.issue_id;
861
+
`)
862
+
if err != nil {
863
+
return err
864
+
}
865
+
866
+
// drop old table
867
+
_, err = tx.Exec(`drop table comments`)
868
+
return err
869
+
})
870
+
706
871
return &DB{db}, nil
707
872
}
708
873
···
747
912
}
748
913
749
914
return nil
915
+
}
916
+
917
+
func (d *DB) Close() error {
918
+
return d.DB.Close()
750
919
}
751
920
752
921
type filter struct {
+4
-4
appview/db/follow.go
+4
-4
appview/db/follow.go
···
56
56
}
57
57
58
58
type FollowStats struct {
59
-
Followers int
60
-
Following int
59
+
Followers int64
60
+
Following int64
61
61
}
62
62
63
63
func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
64
-
followers, following := 0, 0
64
+
var followers, following int64
65
65
err := e.QueryRow(
66
66
`SELECT
67
67
COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers,
···
122
122
123
123
for rows.Next() {
124
124
var did string
125
-
var followers, following int
125
+
var followers, following int64
126
126
if err := rows.Scan(&did, &followers, &following); err != nil {
127
127
return nil, err
128
128
}
+410
-453
appview/db/issues.go
+410
-453
appview/db/issues.go
···
3
3
import (
4
4
"database/sql"
5
5
"fmt"
6
-
mathrand "math/rand/v2"
6
+
"maps"
7
+
"slices"
8
+
"sort"
7
9
"strings"
8
10
"time"
9
11
···
13
15
)
14
16
15
17
type Issue struct {
16
-
ID int64
17
-
RepoAt syntax.ATURI
18
-
OwnerDid string
19
-
IssueId int
20
-
Rkey string
21
-
Created time.Time
22
-
Title string
23
-
Body string
24
-
Open bool
18
+
Id int64
19
+
Did string
20
+
Rkey string
21
+
RepoAt syntax.ATURI
22
+
IssueId int
23
+
Created time.Time
24
+
Edited *time.Time
25
+
Deleted *time.Time
26
+
Title string
27
+
Body string
28
+
Open bool
25
29
26
30
// optionally, populate this when querying for reverse mappings
27
31
// like comment counts, parent repo etc.
28
-
Metadata *IssueMetadata
32
+
Comments []IssueComment
33
+
Repo *Repo
29
34
}
30
35
31
-
type IssueMetadata struct {
32
-
CommentCount int
33
-
Repo *Repo
34
-
// labels, assignee etc.
36
+
func (i *Issue) AtUri() syntax.ATURI {
37
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
35
38
}
36
39
37
-
type Comment struct {
38
-
OwnerDid string
39
-
RepoAt syntax.ATURI
40
-
Rkey string
41
-
Issue int
42
-
CommentId int
43
-
Body string
44
-
Created *time.Time
45
-
Deleted *time.Time
46
-
Edited *time.Time
40
+
func (i *Issue) AsRecord() tangled.RepoIssue {
41
+
return tangled.RepoIssue{
42
+
Repo: i.RepoAt.String(),
43
+
Title: i.Title,
44
+
Body: &i.Body,
45
+
CreatedAt: i.Created.Format(time.RFC3339),
46
+
}
47
47
}
48
48
49
-
func (i *Issue) AtUri() syntax.ATURI {
50
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey))
49
+
func (i *Issue) State() string {
50
+
if i.Open {
51
+
return "open"
52
+
}
53
+
return "closed"
54
+
}
55
+
56
+
type CommentListItem struct {
57
+
Self *IssueComment
58
+
Replies []*IssueComment
59
+
}
60
+
61
+
func (i *Issue) CommentList() []CommentListItem {
62
+
// Create a map to quickly find comments by their aturi
63
+
toplevel := make(map[string]*CommentListItem)
64
+
var replies []*IssueComment
65
+
66
+
// collect top level comments into the map
67
+
for _, comment := range i.Comments {
68
+
if comment.IsTopLevel() {
69
+
toplevel[comment.AtUri().String()] = &CommentListItem{
70
+
Self: &comment,
71
+
}
72
+
} else {
73
+
replies = append(replies, &comment)
74
+
}
75
+
}
76
+
77
+
for _, r := range replies {
78
+
parentAt := *r.ReplyTo
79
+
if parent, exists := toplevel[parentAt]; exists {
80
+
parent.Replies = append(parent.Replies, r)
81
+
}
82
+
}
83
+
84
+
var listing []CommentListItem
85
+
for _, v := range toplevel {
86
+
listing = append(listing, *v)
87
+
}
88
+
89
+
// sort everything
90
+
sortFunc := func(a, b *IssueComment) bool {
91
+
return a.Created.Before(b.Created)
92
+
}
93
+
sort.Slice(listing, func(i, j int) bool {
94
+
return sortFunc(listing[i].Self, listing[j].Self)
95
+
})
96
+
for _, r := range listing {
97
+
sort.Slice(r.Replies, func(i, j int) bool {
98
+
return sortFunc(r.Replies[i], r.Replies[j])
99
+
})
100
+
}
101
+
102
+
return listing
51
103
}
52
104
53
105
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
···
62
114
}
63
115
64
116
return Issue{
65
-
RepoAt: syntax.ATURI(record.Repo),
66
-
OwnerDid: did,
67
-
Rkey: rkey,
68
-
Created: created,
69
-
Title: record.Title,
70
-
Body: body,
71
-
Open: true, // new issues are open by default
117
+
RepoAt: syntax.ATURI(record.Repo),
118
+
Did: did,
119
+
Rkey: rkey,
120
+
Created: created,
121
+
Title: record.Title,
122
+
Body: body,
123
+
Open: true, // new issues are open by default
72
124
}
73
125
}
74
126
75
-
func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) {
76
-
ownerDid := issueUri.Authority().String()
77
-
issueRkey := issueUri.RecordKey().String()
127
+
type IssueComment struct {
128
+
Id int64
129
+
Did string
130
+
Rkey string
131
+
IssueAt string
132
+
ReplyTo *string
133
+
Body string
134
+
Created time.Time
135
+
Edited *time.Time
136
+
Deleted *time.Time
137
+
}
78
138
79
-
var repoAt string
80
-
var issueId int
139
+
func (i *IssueComment) AtUri() syntax.ATURI {
140
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
141
+
}
81
142
82
-
query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?`
83
-
err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId)
84
-
if err != nil {
85
-
return "", 0, err
143
+
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
144
+
return tangled.RepoIssueComment{
145
+
Body: i.Body,
146
+
Issue: i.IssueAt,
147
+
CreatedAt: i.Created.Format(time.RFC3339),
148
+
ReplyTo: i.ReplyTo,
86
149
}
150
+
}
87
151
88
-
return syntax.ATURI(repoAt), issueId, nil
152
+
func (i *IssueComment) IsTopLevel() bool {
153
+
return i.ReplyTo == nil
89
154
}
90
155
91
-
func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) {
156
+
func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
92
157
created, err := time.Parse(time.RFC3339, record.CreatedAt)
93
158
if err != nil {
94
159
created = time.Now()
95
160
}
96
161
97
162
ownerDid := did
98
-
if record.Owner != nil {
99
-
ownerDid = *record.Owner
100
-
}
101
163
102
-
issueUri, err := syntax.ParseATURI(record.Issue)
103
-
if err != nil {
104
-
return Comment{}, err
105
-
}
106
-
107
-
repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri)
108
-
if err != nil {
109
-
return Comment{}, err
164
+
if _, err = syntax.ParseATURI(record.Issue); err != nil {
165
+
return nil, err
110
166
}
111
167
112
-
comment := Comment{
113
-
OwnerDid: ownerDid,
114
-
RepoAt: repoAt,
115
-
Rkey: rkey,
116
-
Body: record.Body,
117
-
Issue: issueId,
118
-
CommentId: mathrand.IntN(1000000),
119
-
Created: &created,
168
+
comment := IssueComment{
169
+
Did: ownerDid,
170
+
Rkey: rkey,
171
+
Body: record.Body,
172
+
IssueAt: record.Issue,
173
+
ReplyTo: record.ReplyTo,
174
+
Created: created,
120
175
}
121
176
122
-
return comment, nil
177
+
return &comment, nil
123
178
}
124
179
125
-
func NewIssue(tx *sql.Tx, issue *Issue) error {
126
-
defer tx.Rollback()
127
-
180
+
func PutIssue(tx *sql.Tx, issue *Issue) error {
181
+
// ensure sequence exists
128
182
_, err := tx.Exec(`
129
183
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
130
184
values (?, 1)
131
-
`, issue.RepoAt)
185
+
`, issue.RepoAt)
132
186
if err != nil {
133
187
return err
134
188
}
135
189
136
-
var nextId int
137
-
err = tx.QueryRow(`
138
-
update repo_issue_seqs
139
-
set next_issue_id = next_issue_id + 1
140
-
where repo_at = ?
141
-
returning next_issue_id - 1
142
-
`, issue.RepoAt).Scan(&nextId)
143
-
if err != nil {
190
+
issues, err := GetIssues(
191
+
tx,
192
+
FilterEq("did", issue.Did),
193
+
FilterEq("rkey", issue.Rkey),
194
+
)
195
+
switch {
196
+
case err != nil:
144
197
return err
145
-
}
146
-
147
-
issue.IssueId = nextId
198
+
case len(issues) == 0:
199
+
return createNewIssue(tx, issue)
200
+
case len(issues) != 1: // should be unreachable
201
+
return fmt.Errorf("invalid number of issues returned: %d", len(issues))
202
+
default:
203
+
// if content is identical, do not edit
204
+
existingIssue := issues[0]
205
+
if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body {
206
+
return nil
207
+
}
148
208
149
-
res, err := tx.Exec(`
150
-
insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body)
151
-
values (?, ?, ?, ?, ?, ?, ?)
152
-
`, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body)
153
-
if err != nil {
154
-
return err
209
+
issue.Id = existingIssue.Id
210
+
issue.IssueId = existingIssue.IssueId
211
+
return updateIssue(tx, issue)
155
212
}
213
+
}
156
214
157
-
lastID, err := res.LastInsertId()
215
+
func createNewIssue(tx *sql.Tx, issue *Issue) error {
216
+
// get next issue_id
217
+
var newIssueId int
218
+
err := tx.QueryRow(`
219
+
update repo_issue_seqs
220
+
set next_issue_id = next_issue_id + 1
221
+
where repo_at = ?
222
+
returning next_issue_id - 1
223
+
`, issue.RepoAt).Scan(&newIssueId)
158
224
if err != nil {
159
225
return err
160
226
}
161
-
issue.ID = lastID
162
227
163
-
if err := tx.Commit(); err != nil {
164
-
return err
165
-
}
228
+
// insert new issue
229
+
row := tx.QueryRow(`
230
+
insert into issues (repo_at, did, rkey, issue_id, title, body)
231
+
values (?, ?, ?, ?, ?, ?)
232
+
returning rowid, issue_id
233
+
`, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body)
166
234
167
-
return nil
235
+
return row.Scan(&issue.Id, &issue.IssueId)
168
236
}
169
237
170
-
func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
171
-
var issueAt string
172
-
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
173
-
return issueAt, err
174
-
}
175
-
176
-
func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
177
-
var ownerDid string
178
-
err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid)
179
-
return ownerDid, err
180
-
}
181
-
182
-
func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
183
-
var issues []Issue
184
-
openValue := 0
185
-
if isOpen {
186
-
openValue = 1
187
-
}
188
-
189
-
rows, err := e.Query(
190
-
`
191
-
with numbered_issue as (
192
-
select
193
-
i.id,
194
-
i.owner_did,
195
-
i.rkey,
196
-
i.issue_id,
197
-
i.created,
198
-
i.title,
199
-
i.body,
200
-
i.open,
201
-
count(c.id) as comment_count,
202
-
row_number() over (order by i.created desc) as row_num
203
-
from
204
-
issues i
205
-
left join
206
-
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
207
-
where
208
-
i.repo_at = ? and i.open = ?
209
-
group by
210
-
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
211
-
)
212
-
select
213
-
id,
214
-
owner_did,
215
-
rkey,
216
-
issue_id,
217
-
created,
218
-
title,
219
-
body,
220
-
open,
221
-
comment_count
222
-
from
223
-
numbered_issue
224
-
where
225
-
row_num between ? and ?`,
226
-
repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
227
-
if err != nil {
228
-
return nil, err
229
-
}
230
-
defer rows.Close()
231
-
232
-
for rows.Next() {
233
-
var issue Issue
234
-
var createdAt string
235
-
var metadata IssueMetadata
236
-
err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
237
-
if err != nil {
238
-
return nil, err
239
-
}
240
-
241
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
242
-
if err != nil {
243
-
return nil, err
244
-
}
245
-
issue.Created = createdTime
246
-
issue.Metadata = &metadata
247
-
248
-
issues = append(issues, issue)
249
-
}
250
-
251
-
if err := rows.Err(); err != nil {
252
-
return nil, err
253
-
}
254
-
255
-
return issues, nil
238
+
func updateIssue(tx *sql.Tx, issue *Issue) error {
239
+
// update existing issue
240
+
_, err := tx.Exec(`
241
+
update issues
242
+
set title = ?, body = ?, edited = ?
243
+
where did = ? and rkey = ?
244
+
`, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
245
+
return err
256
246
}
257
247
258
-
func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) {
259
-
issues := make([]Issue, 0, limit)
248
+
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
249
+
issueMap := make(map[string]*Issue) // at-uri -> issue
260
250
261
251
var conditions []string
262
252
var args []any
253
+
263
254
for _, filter := range filters {
264
255
conditions = append(conditions, filter.Condition())
265
256
args = append(args, filter.Arg()...)
···
269
260
if conditions != nil {
270
261
whereClause = " where " + strings.Join(conditions, " and ")
271
262
}
272
-
limitClause := ""
273
-
if limit != 0 {
274
-
limitClause = fmt.Sprintf(" limit %d ", limit)
275
-
}
263
+
264
+
pLower := FilterGte("row_num", page.Offset+1)
265
+
pUpper := FilterLte("row_num", page.Offset+page.Limit)
266
+
267
+
args = append(args, pLower.Arg()...)
268
+
args = append(args, pUpper.Arg()...)
269
+
pagination := " where " + pLower.Condition() + " and " + pUpper.Condition()
276
270
277
271
query := fmt.Sprintf(
278
-
`select
279
-
i.id,
280
-
i.owner_did,
281
-
i.repo_at,
282
-
i.issue_id,
283
-
i.created,
284
-
i.title,
285
-
i.body,
286
-
i.open
287
-
from
288
-
issues i
272
+
`
273
+
select * from (
274
+
select
275
+
id,
276
+
did,
277
+
rkey,
278
+
repo_at,
279
+
issue_id,
280
+
title,
281
+
body,
282
+
open,
283
+
created,
284
+
edited,
285
+
deleted,
286
+
row_number() over (order by created desc) as row_num
287
+
from
288
+
issues
289
+
%s
290
+
) ranked_issues
289
291
%s
290
-
order by
291
-
i.created desc
292
-
%s`,
293
-
whereClause, limitClause)
292
+
`,
293
+
whereClause,
294
+
pagination,
295
+
)
294
296
295
297
rows, err := e.Query(query, args...)
296
298
if err != nil {
297
-
return nil, err
299
+
return nil, fmt.Errorf("failed to query issues table: %w", err)
298
300
}
299
301
defer rows.Close()
300
302
301
303
for rows.Next() {
302
304
var issue Issue
303
-
var issueCreatedAt string
305
+
var createdAt string
306
+
var editedAt, deletedAt sql.Null[string]
307
+
var rowNum int64
304
308
err := rows.Scan(
305
-
&issue.ID,
306
-
&issue.OwnerDid,
309
+
&issue.Id,
310
+
&issue.Did,
311
+
&issue.Rkey,
307
312
&issue.RepoAt,
308
313
&issue.IssueId,
309
-
&issueCreatedAt,
310
314
&issue.Title,
311
315
&issue.Body,
312
316
&issue.Open,
317
+
&createdAt,
318
+
&editedAt,
319
+
&deletedAt,
320
+
&rowNum,
313
321
)
314
322
if err != nil {
315
-
return nil, err
323
+
return nil, fmt.Errorf("failed to scan issue: %w", err)
324
+
}
325
+
326
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
327
+
issue.Created = t
328
+
}
329
+
330
+
if editedAt.Valid {
331
+
if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil {
332
+
issue.Edited = &t
333
+
}
316
334
}
317
335
318
-
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
319
-
if err != nil {
320
-
return nil, err
336
+
if deletedAt.Valid {
337
+
if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil {
338
+
issue.Deleted = &t
339
+
}
321
340
}
322
-
issue.Created = issueCreatedTime
323
341
324
-
issues = append(issues, issue)
342
+
atUri := issue.AtUri().String()
343
+
issueMap[atUri] = &issue
325
344
}
326
345
327
-
if err := rows.Err(); err != nil {
328
-
return nil, err
346
+
// collect reverse repos
347
+
repoAts := make([]string, 0, len(issueMap)) // or just []string{}
348
+
for _, issue := range issueMap {
349
+
repoAts = append(repoAts, string(issue.RepoAt))
329
350
}
330
351
331
-
return issues, nil
332
-
}
333
-
334
-
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
335
-
return GetIssuesWithLimit(e, 0, filters...)
336
-
}
337
-
338
-
// timeframe here is directly passed into the sql query filter, and any
339
-
// timeframe in the past should be negative; e.g.: "-3 months"
340
-
func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
341
-
var issues []Issue
342
-
343
-
rows, err := e.Query(
344
-
`select
345
-
i.id,
346
-
i.owner_did,
347
-
i.rkey,
348
-
i.repo_at,
349
-
i.issue_id,
350
-
i.created,
351
-
i.title,
352
-
i.body,
353
-
i.open,
354
-
r.did,
355
-
r.name,
356
-
r.knot,
357
-
r.rkey,
358
-
r.created
359
-
from
360
-
issues i
361
-
join
362
-
repos r on i.repo_at = r.at_uri
363
-
where
364
-
i.owner_did = ? and i.created >= date ('now', ?)
365
-
order by
366
-
i.created desc`,
367
-
ownerDid, timeframe)
352
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
368
353
if err != nil {
369
-
return nil, err
354
+
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
370
355
}
371
-
defer rows.Close()
372
356
373
-
for rows.Next() {
374
-
var issue Issue
375
-
var issueCreatedAt, repoCreatedAt string
376
-
var repo Repo
377
-
err := rows.Scan(
378
-
&issue.ID,
379
-
&issue.OwnerDid,
380
-
&issue.Rkey,
381
-
&issue.RepoAt,
382
-
&issue.IssueId,
383
-
&issueCreatedAt,
384
-
&issue.Title,
385
-
&issue.Body,
386
-
&issue.Open,
387
-
&repo.Did,
388
-
&repo.Name,
389
-
&repo.Knot,
390
-
&repo.Rkey,
391
-
&repoCreatedAt,
392
-
)
393
-
if err != nil {
394
-
return nil, err
395
-
}
357
+
repoMap := make(map[string]*Repo)
358
+
for i := range repos {
359
+
repoMap[string(repos[i].RepoAt())] = &repos[i]
360
+
}
396
361
397
-
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
398
-
if err != nil {
399
-
return nil, err
362
+
for issueAt, i := range issueMap {
363
+
if r, ok := repoMap[string(i.RepoAt)]; ok {
364
+
i.Repo = r
365
+
} else {
366
+
// do not show up the issue if the repo is deleted
367
+
// TODO: foreign key where?
368
+
delete(issueMap, issueAt)
400
369
}
401
-
issue.Created = issueCreatedTime
370
+
}
402
371
403
-
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
404
-
if err != nil {
405
-
return nil, err
406
-
}
407
-
repo.Created = repoCreatedTime
372
+
// collect comments
373
+
issueAts := slices.Collect(maps.Keys(issueMap))
374
+
comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
375
+
if err != nil {
376
+
return nil, fmt.Errorf("failed to query comments: %w", err)
377
+
}
408
378
409
-
issue.Metadata = &IssueMetadata{
410
-
Repo: &repo,
379
+
for i := range comments {
380
+
issueAt := comments[i].IssueAt
381
+
if issue, ok := issueMap[issueAt]; ok {
382
+
issue.Comments = append(issue.Comments, comments[i])
411
383
}
412
-
413
-
issues = append(issues, issue)
414
384
}
415
385
416
-
if err := rows.Err(); err != nil {
417
-
return nil, err
386
+
var issues []Issue
387
+
for _, i := range issueMap {
388
+
issues = append(issues, *i)
418
389
}
419
390
391
+
sort.Slice(issues, func(i, j int) bool {
392
+
return issues[i].Created.After(issues[j].Created)
393
+
})
394
+
420
395
return issues, nil
421
396
}
422
397
398
+
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
399
+
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
400
+
}
401
+
423
402
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
424
403
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
425
404
row := e.QueryRow(query, repoAt, issueId)
426
405
427
406
var issue Issue
428
407
var createdAt string
429
-
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
408
+
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
430
409
if err != nil {
431
410
return nil, err
432
411
}
···
440
419
return &issue, nil
441
420
}
442
421
443
-
func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
444
-
query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
445
-
row := e.QueryRow(query, repoAt, issueId)
446
-
447
-
var issue Issue
448
-
var createdAt string
449
-
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
422
+
func AddIssueComment(e Execer, c IssueComment) (int64, error) {
423
+
result, err := e.Exec(
424
+
`insert into issue_comments (
425
+
did,
426
+
rkey,
427
+
issue_at,
428
+
body,
429
+
reply_to,
430
+
created,
431
+
edited
432
+
)
433
+
values (?, ?, ?, ?, ?, ?, null)
434
+
on conflict(did, rkey) do update set
435
+
issue_at = excluded.issue_at,
436
+
body = excluded.body,
437
+
edited = case
438
+
when
439
+
issue_comments.issue_at != excluded.issue_at
440
+
or issue_comments.body != excluded.body
441
+
or issue_comments.reply_to != excluded.reply_to
442
+
then ?
443
+
else issue_comments.edited
444
+
end`,
445
+
c.Did,
446
+
c.Rkey,
447
+
c.IssueAt,
448
+
c.Body,
449
+
c.ReplyTo,
450
+
c.Created.Format(time.RFC3339),
451
+
time.Now().Format(time.RFC3339),
452
+
)
450
453
if err != nil {
451
-
return nil, nil, err
454
+
return 0, err
452
455
}
453
456
454
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
457
+
id, err := result.LastInsertId()
455
458
if err != nil {
456
-
return nil, nil, err
459
+
return 0, err
457
460
}
458
-
issue.Created = createdTime
461
+
462
+
return id, nil
463
+
}
459
464
460
-
comments, err := GetComments(e, repoAt, issueId)
461
-
if err != nil {
462
-
return nil, nil, err
465
+
func DeleteIssueComments(e Execer, filters ...filter) error {
466
+
var conditions []string
467
+
var args []any
468
+
for _, filter := range filters {
469
+
conditions = append(conditions, filter.Condition())
470
+
args = append(args, filter.Arg()...)
463
471
}
464
472
465
-
return &issue, comments, nil
466
-
}
473
+
whereClause := ""
474
+
if conditions != nil {
475
+
whereClause = " where " + strings.Join(conditions, " and ")
476
+
}
467
477
468
-
func NewIssueComment(e Execer, comment *Comment) error {
469
-
query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
470
-
_, err := e.Exec(
471
-
query,
472
-
comment.OwnerDid,
473
-
comment.RepoAt,
474
-
comment.Rkey,
475
-
comment.Issue,
476
-
comment.CommentId,
477
-
comment.Body,
478
-
)
478
+
query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
479
+
480
+
_, err := e.Exec(query, args...)
479
481
return err
480
482
}
481
483
482
-
func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) {
483
-
var comments []Comment
484
+
func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) {
485
+
var comments []IssueComment
486
+
487
+
var conditions []string
488
+
var args []any
489
+
for _, filter := range filters {
490
+
conditions = append(conditions, filter.Condition())
491
+
args = append(args, filter.Arg()...)
492
+
}
493
+
494
+
whereClause := ""
495
+
if conditions != nil {
496
+
whereClause = " where " + strings.Join(conditions, " and ")
497
+
}
484
498
485
-
rows, err := e.Query(`
499
+
query := fmt.Sprintf(`
486
500
select
487
-
owner_did,
488
-
issue_id,
489
-
comment_id,
501
+
id,
502
+
did,
490
503
rkey,
504
+
issue_at,
505
+
reply_to,
491
506
body,
492
507
created,
493
508
edited,
494
509
deleted
495
510
from
496
-
comments
497
-
where
498
-
repo_at = ? and issue_id = ?
499
-
order by
500
-
created asc`,
501
-
repoAt,
502
-
issueId,
503
-
)
504
-
if err == sql.ErrNoRows {
505
-
return []Comment{}, nil
506
-
}
511
+
issue_comments
512
+
%s
513
+
`, whereClause)
514
+
515
+
rows, err := e.Query(query, args...)
507
516
if err != nil {
508
517
return nil, err
509
518
}
510
-
defer rows.Close()
511
519
512
520
for rows.Next() {
513
-
var comment Comment
514
-
var createdAt string
515
-
var deletedAt, editedAt, rkey sql.NullString
516
-
err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt)
521
+
var comment IssueComment
522
+
var created string
523
+
var rkey, edited, deleted, replyTo sql.Null[string]
524
+
err := rows.Scan(
525
+
&comment.Id,
526
+
&comment.Did,
527
+
&rkey,
528
+
&comment.IssueAt,
529
+
&replyTo,
530
+
&comment.Body,
531
+
&created,
532
+
&edited,
533
+
&deleted,
534
+
)
517
535
if err != nil {
518
536
return nil, err
519
537
}
520
538
521
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
522
-
if err != nil {
523
-
return nil, err
539
+
// this is a remnant from old times, newer comments always have rkey
540
+
if rkey.Valid {
541
+
comment.Rkey = rkey.V
524
542
}
525
-
comment.Created = &createdAtTime
526
543
527
-
if deletedAt.Valid {
528
-
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
529
-
if err != nil {
530
-
return nil, err
544
+
if t, err := time.Parse(time.RFC3339, created); err == nil {
545
+
comment.Created = t
546
+
}
547
+
548
+
if edited.Valid {
549
+
if t, err := time.Parse(time.RFC3339, edited.V); err == nil {
550
+
comment.Edited = &t
531
551
}
532
-
comment.Deleted = &deletedTime
533
552
}
534
553
535
-
if editedAt.Valid {
536
-
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
537
-
if err != nil {
538
-
return nil, err
554
+
if deleted.Valid {
555
+
if t, err := time.Parse(time.RFC3339, deleted.V); err == nil {
556
+
comment.Deleted = &t
539
557
}
540
-
comment.Edited = &editedTime
541
558
}
542
559
543
-
if rkey.Valid {
544
-
comment.Rkey = rkey.String
560
+
if replyTo.Valid {
561
+
comment.ReplyTo = &replyTo.V
545
562
}
546
563
547
564
comments = append(comments, comment)
548
565
}
549
566
550
-
if err := rows.Err(); err != nil {
567
+
if err = rows.Err(); err != nil {
551
568
return nil, err
552
569
}
553
570
554
571
return comments, nil
555
572
}
556
573
557
-
func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) {
558
-
query := `
559
-
select
560
-
owner_did, body, rkey, created, deleted, edited
561
-
from
562
-
comments where repo_at = ? and issue_id = ? and comment_id = ?
563
-
`
564
-
row := e.QueryRow(query, repoAt, issueId, commentId)
565
-
566
-
var comment Comment
567
-
var createdAt string
568
-
var deletedAt, editedAt, rkey sql.NullString
569
-
err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt)
570
-
if err != nil {
571
-
return nil, err
574
+
func DeleteIssues(e Execer, filters ...filter) error {
575
+
var conditions []string
576
+
var args []any
577
+
for _, filter := range filters {
578
+
conditions = append(conditions, filter.Condition())
579
+
args = append(args, filter.Arg()...)
572
580
}
573
581
574
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
575
-
if err != nil {
576
-
return nil, err
582
+
whereClause := ""
583
+
if conditions != nil {
584
+
whereClause = " where " + strings.Join(conditions, " and ")
577
585
}
578
-
comment.Created = &createdTime
579
586
580
-
if deletedAt.Valid {
581
-
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
582
-
if err != nil {
583
-
return nil, err
584
-
}
585
-
comment.Deleted = &deletedTime
586
-
}
587
+
query := fmt.Sprintf(`delete from issues %s`, whereClause)
588
+
_, err := e.Exec(query, args...)
589
+
return err
590
+
}
587
591
588
-
if editedAt.Valid {
589
-
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
590
-
if err != nil {
591
-
return nil, err
592
-
}
593
-
comment.Edited = &editedTime
592
+
func CloseIssues(e Execer, filters ...filter) error {
593
+
var conditions []string
594
+
var args []any
595
+
for _, filter := range filters {
596
+
conditions = append(conditions, filter.Condition())
597
+
args = append(args, filter.Arg()...)
594
598
}
595
599
596
-
if rkey.Valid {
597
-
comment.Rkey = rkey.String
600
+
whereClause := ""
601
+
if conditions != nil {
602
+
whereClause = " where " + strings.Join(conditions, " and ")
598
603
}
599
604
600
-
comment.RepoAt = repoAt
601
-
comment.Issue = issueId
602
-
comment.CommentId = commentId
603
-
604
-
return &comment, nil
605
-
}
606
-
607
-
func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error {
608
-
_, err := e.Exec(
609
-
`
610
-
update comments
611
-
set body = ?,
612
-
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
613
-
where repo_at = ? and issue_id = ? and comment_id = ?
614
-
`, newBody, repoAt, issueId, commentId)
605
+
query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause)
606
+
_, err := e.Exec(query, args...)
615
607
return err
616
608
}
617
609
618
-
func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error {
619
-
_, err := e.Exec(
620
-
`
621
-
update comments
622
-
set body = "",
623
-
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
624
-
where repo_at = ? and issue_id = ? and comment_id = ?
625
-
`, repoAt, issueId, commentId)
626
-
return err
627
-
}
610
+
func ReopenIssues(e Execer, filters ...filter) error {
611
+
var conditions []string
612
+
var args []any
613
+
for _, filter := range filters {
614
+
conditions = append(conditions, filter.Condition())
615
+
args = append(args, filter.Arg()...)
616
+
}
628
617
629
-
func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error {
630
-
_, err := e.Exec(
631
-
`
632
-
update comments
633
-
set body = ?,
634
-
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
635
-
where owner_did = ? and rkey = ?
636
-
`, newBody, ownerDid, rkey)
637
-
return err
638
-
}
618
+
whereClause := ""
619
+
if conditions != nil {
620
+
whereClause = " where " + strings.Join(conditions, " and ")
621
+
}
639
622
640
-
func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error {
641
-
_, err := e.Exec(
642
-
`
643
-
update comments
644
-
set body = "",
645
-
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
646
-
where owner_did = ? and rkey = ?
647
-
`, ownerDid, rkey)
648
-
return err
649
-
}
650
-
651
-
func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error {
652
-
_, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey)
653
-
return err
654
-
}
655
-
656
-
func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error {
657
-
_, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey)
658
-
return err
659
-
}
660
-
661
-
func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
662
-
_, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId)
663
-
return err
664
-
}
665
-
666
-
func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
667
-
_, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId)
623
+
query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause)
624
+
_, err := e.Exec(query, args...)
668
625
return err
669
626
}
670
627
+23
-5
appview/db/profile.go
+23
-5
appview/db/profile.go
···
22
22
ByMonth []ByMonth
23
23
}
24
24
25
+
func (p *ProfileTimeline) IsEmpty() bool {
26
+
if p == nil {
27
+
return true
28
+
}
29
+
30
+
for _, m := range p.ByMonth {
31
+
if !m.IsEmpty() {
32
+
return false
33
+
}
34
+
}
35
+
36
+
return true
37
+
}
38
+
25
39
type ByMonth struct {
26
40
RepoEvents []RepoEvent
27
41
IssueEvents IssueEvents
···
118
132
*items = append(*items, &pull)
119
133
}
120
134
121
-
issues, err := GetIssuesByOwnerDid(e, forDid, timeframe)
135
+
issues, err := GetIssues(
136
+
e,
137
+
FilterEq("did", forDid),
138
+
FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)),
139
+
)
122
140
if err != nil {
123
141
return nil, fmt.Errorf("error getting issues by owner did: %w", err)
124
142
}
···
137
155
*items = append(*items, &issue)
138
156
}
139
157
140
-
repos, err := GetAllReposByDid(e, forDid)
158
+
repos, err := GetRepos(e, 0, FilterEq("did", forDid))
141
159
if err != nil {
142
160
return nil, fmt.Errorf("error getting all repos by did: %w", err)
143
161
}
···
535
553
query = `select count(id) from pulls where owner_did = ? and state = ?`
536
554
args = append(args, did, PullOpen)
537
555
case VanityStatOpenIssueCount:
538
-
query = `select count(id) from issues where owner_did = ? and open = 1`
556
+
query = `select count(id) from issues where did = ? and open = 1`
539
557
args = append(args, did)
540
558
case VanityStatClosedIssueCount:
541
-
query = `select count(id) from issues where owner_did = ? and open = 0`
559
+
query = `select count(id) from issues where did = ? and open = 0`
542
560
args = append(args, did)
543
561
case VanityStatRepositoryCount:
544
562
query = `select count(id) from repos where did = ?`
···
572
590
}
573
591
574
592
// ensure all pinned repos are either own repos or collaborating repos
575
-
repos, err := GetAllReposByDid(e, profile.Did)
593
+
repos, err := GetRepos(e, 0, FilterEq("did", profile.Did))
576
594
if err != nil {
577
595
log.Printf("getting repos for %s: %s", profile.Did, err)
578
596
}
+4
-4
appview/db/punchcard.go
+4
-4
appview/db/punchcard.go
···
29
29
Punches []Punch
30
30
}
31
31
32
-
func MakePunchcard(e Execer, filters ...filter) (Punchcard, error) {
33
-
punchcard := Punchcard{}
32
+
func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) {
33
+
punchcard := &Punchcard{}
34
34
now := time.Now()
35
35
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
36
36
endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC)
···
63
63
64
64
rows, err := e.Query(query, args...)
65
65
if err != nil {
66
-
return punchcard, err
66
+
return nil, err
67
67
}
68
68
defer rows.Close()
69
69
···
72
72
var date string
73
73
var count sql.NullInt64
74
74
if err := rows.Scan(&date, &count); err != nil {
75
-
return punchcard, err
75
+
return nil, err
76
76
}
77
77
78
78
punch.Date, err = time.Parse(time.DateOnly, date)
+17
-17
appview/db/registration.go
+17
-17
appview/db/registration.go
···
10
10
// Registration represents a knot registration. Knot would've been a better
11
11
// name but we're stuck with this for historical reasons.
12
12
type Registration struct {
13
-
Id int64
14
-
Domain string
15
-
ByDid string
16
-
Created *time.Time
17
-
Registered *time.Time
18
-
ReadOnly bool
13
+
Id int64
14
+
Domain string
15
+
ByDid string
16
+
Created *time.Time
17
+
Registered *time.Time
18
+
NeedsUpgrade bool
19
19
}
20
20
21
21
func (r *Registration) Status() Status {
22
-
if r.ReadOnly {
23
-
return ReadOnly
22
+
if r.NeedsUpgrade {
23
+
return NeedsUpgrade
24
24
} else if r.Registered != nil {
25
25
return Registered
26
26
} else {
···
32
32
return r.Status() == Registered
33
33
}
34
34
35
-
func (r *Registration) IsReadOnly() bool {
36
-
return r.Status() == ReadOnly
35
+
func (r *Registration) IsNeedsUpgrade() bool {
36
+
return r.Status() == NeedsUpgrade
37
37
}
38
38
39
39
func (r *Registration) IsPending() bool {
···
45
45
const (
46
46
Registered Status = iota
47
47
Pending
48
-
ReadOnly
48
+
NeedsUpgrade
49
49
)
50
50
51
51
func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) {
···
64
64
}
65
65
66
66
query := fmt.Sprintf(`
67
-
select id, domain, did, created, registered, read_only
67
+
select id, domain, did, created, registered, needs_upgrade
68
68
from registrations
69
69
%s
70
70
order by created
···
80
80
for rows.Next() {
81
81
var createdAt string
82
82
var registeredAt sql.Null[string]
83
-
var readOnly int
83
+
var needsUpgrade int
84
84
var reg Registration
85
85
86
-
err = rows.Scan(®.Id, ®.Domain, ®.ByDid, &createdAt, ®isteredAt, &readOnly)
86
+
err = rows.Scan(®.Id, ®.Domain, ®.ByDid, &createdAt, ®isteredAt, &needsUpgrade)
87
87
if err != nil {
88
88
return nil, err
89
89
}
···
98
98
}
99
99
}
100
100
101
-
if readOnly != 0 {
102
-
reg.ReadOnly = true
101
+
if needsUpgrade != 0 {
102
+
reg.NeedsUpgrade = true
103
103
}
104
104
105
105
registrations = append(registrations, reg)
···
116
116
args = append(args, filter.Arg()...)
117
117
}
118
118
119
-
query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0"
119
+
query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), needs_upgrade = 0"
120
120
if len(conditions) > 0 {
121
121
query += " where " + strings.Join(conditions, " and ")
122
122
}
+19
-125
appview/db/repos.go
+19
-125
appview/db/repos.go
···
2
2
3
3
import (
4
4
"database/sql"
5
+
"errors"
5
6
"fmt"
6
7
"log"
7
8
"slices"
···
36
37
func (r Repo) DidSlashRepo() string {
37
38
p, _ := securejoin.SecureJoin(r.Did, r.Name)
38
39
return p
39
-
}
40
-
41
-
func GetAllRepos(e Execer, limit int) ([]Repo, error) {
42
-
var repos []Repo
43
-
44
-
rows, err := e.Query(
45
-
`select did, name, knot, rkey, description, created, source
46
-
from repos
47
-
order by created desc
48
-
limit ?
49
-
`,
50
-
limit,
51
-
)
52
-
if err != nil {
53
-
return nil, err
54
-
}
55
-
defer rows.Close()
56
-
57
-
for rows.Next() {
58
-
var repo Repo
59
-
err := scanRepo(
60
-
rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source,
61
-
)
62
-
if err != nil {
63
-
return nil, err
64
-
}
65
-
repos = append(repos, repo)
66
-
}
67
-
68
-
if err := rows.Err(); err != nil {
69
-
return nil, err
70
-
}
71
-
72
-
return repos, nil
73
40
}
74
41
75
42
func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) {
···
310
277
311
278
slices.SortFunc(repos, func(a, b Repo) int {
312
279
if a.Created.After(b.Created) {
313
-
return 1
280
+
return -1
314
281
}
315
-
return -1
282
+
return 1
316
283
})
317
284
318
285
return repos, nil
319
286
}
320
287
321
-
func GetAllReposByDid(e Execer, did string) ([]Repo, error) {
322
-
var repos []Repo
323
-
324
-
rows, err := e.Query(
325
-
`select
326
-
r.did,
327
-
r.name,
328
-
r.knot,
329
-
r.rkey,
330
-
r.description,
331
-
r.created,
332
-
count(s.id) as star_count,
333
-
r.source
334
-
from
335
-
repos r
336
-
left join
337
-
stars s on r.at_uri = s.repo_at
338
-
where
339
-
r.did = ?
340
-
group by
341
-
r.at_uri
342
-
order by r.created desc`,
343
-
did)
344
-
if err != nil {
345
-
return nil, err
288
+
func CountRepos(e Execer, filters ...filter) (int64, error) {
289
+
var conditions []string
290
+
var args []any
291
+
for _, filter := range filters {
292
+
conditions = append(conditions, filter.Condition())
293
+
args = append(args, filter.Arg()...)
346
294
}
347
-
defer rows.Close()
348
295
349
-
for rows.Next() {
350
-
var repo Repo
351
-
var repoStats RepoStats
352
-
var createdAt string
353
-
var nullableDescription sql.NullString
354
-
var nullableSource sql.NullString
355
-
356
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource)
357
-
if err != nil {
358
-
return nil, err
359
-
}
360
-
361
-
if nullableDescription.Valid {
362
-
repo.Description = nullableDescription.String
363
-
}
364
-
365
-
if nullableSource.Valid {
366
-
repo.Source = nullableSource.String
367
-
}
368
-
369
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
370
-
if err != nil {
371
-
repo.Created = time.Now()
372
-
} else {
373
-
repo.Created = createdAtTime
374
-
}
296
+
whereClause := ""
297
+
if conditions != nil {
298
+
whereClause = " where " + strings.Join(conditions, " and ")
299
+
}
375
300
376
-
repo.RepoStats = &repoStats
301
+
repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause)
302
+
var count int64
303
+
err := e.QueryRow(repoQuery, args...).Scan(&count)
377
304
378
-
repos = append(repos, repo)
379
-
}
380
-
381
-
if err := rows.Err(); err != nil {
382
-
return nil, err
305
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
306
+
return 0, err
383
307
}
384
308
385
-
return repos, nil
309
+
return count, nil
386
310
}
387
311
388
312
func GetRepo(e Execer, did, name string) (*Repo, error) {
···
570
494
IssueCount IssueCount
571
495
PullCount PullCount
572
496
}
573
-
574
-
func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error {
575
-
var createdAt string
576
-
var nullableDescription sql.NullString
577
-
var nullableSource sql.NullString
578
-
if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil {
579
-
return err
580
-
}
581
-
582
-
if nullableDescription.Valid {
583
-
*description = nullableDescription.String
584
-
} else {
585
-
*description = ""
586
-
}
587
-
588
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
589
-
if err != nil {
590
-
*created = time.Now()
591
-
} else {
592
-
*created = createdAtTime
593
-
}
594
-
595
-
if nullableSource.Valid {
596
-
*source = nullableSource.String
597
-
} else {
598
-
*source = ""
599
-
}
600
-
601
-
return nil
602
-
}
+14
-7
appview/db/spindle.go
+14
-7
appview/db/spindle.go
···
10
10
)
11
11
12
12
type Spindle struct {
13
-
Id int
14
-
Owner syntax.DID
15
-
Instance string
16
-
Verified *time.Time
17
-
Created time.Time
13
+
Id int
14
+
Owner syntax.DID
15
+
Instance string
16
+
Verified *time.Time
17
+
Created time.Time
18
+
NeedsUpgrade bool
18
19
}
19
20
20
21
type SpindleMember struct {
···
42
43
}
43
44
44
45
query := fmt.Sprintf(
45
-
`select id, owner, instance, verified, created
46
+
`select id, owner, instance, verified, created, needs_upgrade
46
47
from spindles
47
48
%s
48
49
order by created
···
61
62
var spindle Spindle
62
63
var createdAt string
63
64
var verified sql.NullString
65
+
var needsUpgrade int
64
66
65
67
if err := rows.Scan(
66
68
&spindle.Id,
···
68
70
&spindle.Instance,
69
71
&verified,
70
72
&createdAt,
73
+
&needsUpgrade,
71
74
); err != nil {
72
75
return nil, err
73
76
}
···
86
89
spindle.Verified = &t
87
90
}
88
91
92
+
if needsUpgrade != 0 {
93
+
spindle.NeedsUpgrade = true
94
+
}
95
+
89
96
spindles = append(spindles, spindle)
90
97
}
91
98
···
115
122
whereClause = " where " + strings.Join(conditions, " and ")
116
123
}
117
124
118
-
query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
125
+
query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now'), needs_upgrade = 0 %s`, whereClause)
119
126
120
127
res, err := e.Exec(query, args...)
121
128
if err != nil {
+26
appview/db/star.go
+26
appview/db/star.go
···
1
1
package db
2
2
3
3
import (
4
+
"database/sql"
5
+
"errors"
4
6
"fmt"
5
7
"log"
6
8
"strings"
···
181
183
}
182
184
183
185
return stars, nil
186
+
}
187
+
188
+
func CountStars(e Execer, filters ...filter) (int64, error) {
189
+
var conditions []string
190
+
var args []any
191
+
for _, filter := range filters {
192
+
conditions = append(conditions, filter.Condition())
193
+
args = append(args, filter.Arg()...)
194
+
}
195
+
196
+
whereClause := ""
197
+
if conditions != nil {
198
+
whereClause = " where " + strings.Join(conditions, " and ")
199
+
}
200
+
201
+
repoQuery := fmt.Sprintf(`select count(1) from stars %s`, whereClause)
202
+
var count int64
203
+
err := e.QueryRow(repoQuery, args...).Scan(&count)
204
+
205
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
206
+
return 0, err
207
+
}
208
+
209
+
return count, nil
184
210
}
185
211
186
212
func GetAllStars(e Execer, limit int) ([]Star, error) {
+24
appview/db/strings.go
+24
appview/db/strings.go
···
206
206
return all, nil
207
207
}
208
208
209
+
func CountStrings(e Execer, filters ...filter) (int64, error) {
210
+
var conditions []string
211
+
var args []any
212
+
for _, filter := range filters {
213
+
conditions = append(conditions, filter.Condition())
214
+
args = append(args, filter.Arg()...)
215
+
}
216
+
217
+
whereClause := ""
218
+
if conditions != nil {
219
+
whereClause = " where " + strings.Join(conditions, " and ")
220
+
}
221
+
222
+
repoQuery := fmt.Sprintf(`select count(1) from strings %s`, whereClause)
223
+
var count int64
224
+
err := e.QueryRow(repoQuery, args...).Scan(&count)
225
+
226
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
227
+
return 0, err
228
+
}
229
+
230
+
return count, nil
231
+
}
232
+
209
233
func DeleteString(e Execer, filters ...filter) error {
210
234
var conditions []string
211
235
var args []any
+12
-14
appview/db/timeline.go
+12
-14
appview/db/timeline.go
···
20
20
*FollowStats
21
21
}
22
22
23
-
const Limit = 50
24
-
25
23
// TODO: this gathers heterogenous events from different sources and aggregates
26
24
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
27
-
func MakeTimeline(e Execer) ([]TimelineEvent, error) {
25
+
func MakeTimeline(e Execer, limit int) ([]TimelineEvent, error) {
28
26
var events []TimelineEvent
29
27
30
-
repos, err := getTimelineRepos(e)
28
+
repos, err := getTimelineRepos(e, limit)
31
29
if err != nil {
32
30
return nil, err
33
31
}
34
32
35
-
stars, err := getTimelineStars(e)
33
+
stars, err := getTimelineStars(e, limit)
36
34
if err != nil {
37
35
return nil, err
38
36
}
39
37
40
-
follows, err := getTimelineFollows(e)
38
+
follows, err := getTimelineFollows(e, limit)
41
39
if err != nil {
42
40
return nil, err
43
41
}
···
51
49
})
52
50
53
51
// Limit the slice to 100 events
54
-
if len(events) > Limit {
55
-
events = events[:Limit]
52
+
if len(events) > limit {
53
+
events = events[:limit]
56
54
}
57
55
58
56
return events, nil
59
57
}
60
58
61
-
func getTimelineRepos(e Execer) ([]TimelineEvent, error) {
62
-
repos, err := GetRepos(e, Limit)
59
+
func getTimelineRepos(e Execer, limit int) ([]TimelineEvent, error) {
60
+
repos, err := GetRepos(e, limit)
63
61
if err != nil {
64
62
return nil, err
65
63
}
···
104
102
return events, nil
105
103
}
106
104
107
-
func getTimelineStars(e Execer) ([]TimelineEvent, error) {
108
-
stars, err := GetStars(e, Limit)
105
+
func getTimelineStars(e Execer, limit int) ([]TimelineEvent, error) {
106
+
stars, err := GetStars(e, limit)
109
107
if err != nil {
110
108
return nil, err
111
109
}
···
131
129
return events, nil
132
130
}
133
131
134
-
func getTimelineFollows(e Execer) ([]TimelineEvent, error) {
135
-
follows, err := GetFollows(e, Limit)
132
+
func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) {
133
+
follows, err := GetFollows(e, limit)
136
134
if err != nil {
137
135
return nil, err
138
136
}
+29
-74
appview/ingester.go
+29
-74
appview/ingester.go
···
5
5
"encoding/json"
6
6
"fmt"
7
7
"log/slog"
8
-
"strings"
8
+
9
9
"time"
10
10
11
11
"github.com/bluesky-social/indigo/atproto/syntax"
···
15
15
"tangled.sh/tangled.sh/core/api/tangled"
16
16
"tangled.sh/tangled.sh/core/appview/config"
17
17
"tangled.sh/tangled.sh/core/appview/db"
18
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
19
18
"tangled.sh/tangled.sh/core/appview/serververify"
19
+
"tangled.sh/tangled.sh/core/appview/validator"
20
20
"tangled.sh/tangled.sh/core/idresolver"
21
21
"tangled.sh/tangled.sh/core/rbac"
22
22
)
···
27
27
IdResolver *idresolver.Resolver
28
28
Config *config.Config
29
29
Logger *slog.Logger
30
+
Validator *validator.Validator
30
31
}
31
32
32
33
type processFunc func(ctx context.Context, e *models.Event) error
···
790
791
}
791
792
792
793
switch e.Commit.Operation {
793
-
case models.CommitOperationCreate:
794
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
794
795
raw := json.RawMessage(e.Commit.Record)
795
796
record := tangled.RepoIssue{}
796
797
err = json.Unmarshal(raw, &record)
···
801
802
802
803
issue := db.IssueFromRecord(did, rkey, record)
803
804
804
-
sanitizer := markup.NewSanitizer()
805
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" {
806
-
return fmt.Errorf("title is empty after HTML sanitization")
807
-
}
808
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" {
809
-
return fmt.Errorf("body is empty after HTML sanitization")
805
+
if err := i.Validator.ValidateIssue(&issue); err != nil {
806
+
return fmt.Errorf("failed to validate issue: %w", err)
810
807
}
811
808
812
809
tx, err := ddb.BeginTx(ctx, nil)
···
814
811
l.Error("failed to begin transaction", "err", err)
815
812
return err
816
813
}
814
+
defer tx.Rollback()
817
815
818
-
err = db.NewIssue(tx, &issue)
816
+
err = db.PutIssue(tx, &issue)
819
817
if err != nil {
820
818
l.Error("failed to create issue", "err", err)
821
819
return err
822
820
}
823
821
824
-
return nil
825
-
826
-
case models.CommitOperationUpdate:
827
-
raw := json.RawMessage(e.Commit.Record)
828
-
record := tangled.RepoIssue{}
829
-
err = json.Unmarshal(raw, &record)
822
+
err = tx.Commit()
830
823
if err != nil {
831
-
l.Error("invalid record", "err", err)
832
-
return err
833
-
}
834
-
835
-
body := ""
836
-
if record.Body != nil {
837
-
body = *record.Body
838
-
}
839
-
840
-
sanitizer := markup.NewSanitizer()
841
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" {
842
-
return fmt.Errorf("title is empty after HTML sanitization")
843
-
}
844
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
845
-
return fmt.Errorf("body is empty after HTML sanitization")
846
-
}
847
-
848
-
err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body)
849
-
if err != nil {
850
-
l.Error("failed to update issue", "err", err)
824
+
l.Error("failed to commit txn", "err", err)
851
825
return err
852
826
}
853
827
854
828
return nil
855
829
856
830
case models.CommitOperationDelete:
857
-
if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil {
831
+
if err := db.DeleteIssues(
832
+
ddb,
833
+
db.FilterEq("did", did),
834
+
db.FilterEq("rkey", rkey),
835
+
); err != nil {
858
836
l.Error("failed to delete", "err", err)
859
837
return fmt.Errorf("failed to delete issue record: %w", err)
860
838
}
···
862
840
return nil
863
841
}
864
842
865
-
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
843
+
return nil
866
844
}
867
845
868
846
func (i *Ingester) ingestIssueComment(e *models.Event) error {
···
880
858
}
881
859
882
860
switch e.Commit.Operation {
883
-
case models.CommitOperationCreate:
861
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
884
862
raw := json.RawMessage(e.Commit.Record)
885
863
record := tangled.RepoIssueComment{}
886
864
err = json.Unmarshal(raw, &record)
887
865
if err != nil {
888
-
l.Error("invalid record", "err", err)
889
-
return err
866
+
return fmt.Errorf("invalid record: %w", err)
890
867
}
891
868
892
869
comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record)
893
870
if err != nil {
894
-
l.Error("failed to parse comment from record", "err", err)
895
-
return err
871
+
return fmt.Errorf("failed to parse comment from record: %w", err)
896
872
}
897
873
898
-
sanitizer := markup.NewSanitizer()
899
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" {
900
-
return fmt.Errorf("body is empty after HTML sanitization")
874
+
if err := i.Validator.ValidateIssueComment(comment); err != nil {
875
+
return fmt.Errorf("failed to validate comment: %w", err)
901
876
}
902
877
903
-
err = db.NewIssueComment(ddb, &comment)
878
+
_, err = db.AddIssueComment(ddb, *comment)
904
879
if err != nil {
905
-
l.Error("failed to create issue comment", "err", err)
906
-
return err
907
-
}
908
-
909
-
return nil
910
-
911
-
case models.CommitOperationUpdate:
912
-
raw := json.RawMessage(e.Commit.Record)
913
-
record := tangled.RepoIssueComment{}
914
-
err = json.Unmarshal(raw, &record)
915
-
if err != nil {
916
-
l.Error("invalid record", "err", err)
917
-
return err
918
-
}
919
-
920
-
sanitizer := markup.NewSanitizer()
921
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" {
922
-
return fmt.Errorf("body is empty after HTML sanitization")
923
-
}
924
-
925
-
err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body)
926
-
if err != nil {
927
-
l.Error("failed to update issue comment", "err", err)
928
-
return err
880
+
return fmt.Errorf("failed to create issue comment: %w", err)
929
881
}
930
882
931
883
return nil
932
884
933
885
case models.CommitOperationDelete:
934
-
if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil {
935
-
l.Error("failed to delete", "err", err)
886
+
if err := db.DeleteIssueComments(
887
+
ddb,
888
+
db.FilterEq("did", did),
889
+
db.FilterEq("rkey", rkey),
890
+
); err != nil {
936
891
return fmt.Errorf("failed to delete issue comment record: %w", err)
937
892
}
938
893
939
894
return nil
940
895
}
941
896
942
-
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
897
+
return nil
943
898
}
+477
-280
appview/issues/issues.go
+477
-280
appview/issues/issues.go
···
1
1
package issues
2
2
3
3
import (
4
+
"context"
5
+
"database/sql"
6
+
"errors"
4
7
"fmt"
5
8
"log"
6
-
mathrand "math/rand/v2"
9
+
"log/slog"
7
10
"net/http"
8
11
"slices"
9
-
"strconv"
10
-
"strings"
11
12
"time"
12
13
13
14
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
-
"github.com/bluesky-social/indigo/atproto/data"
15
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
16
lexutil "github.com/bluesky-social/indigo/lex/util"
16
17
"github.com/go-chi/chi/v5"
17
18
···
21
22
"tangled.sh/tangled.sh/core/appview/notify"
22
23
"tangled.sh/tangled.sh/core/appview/oauth"
23
24
"tangled.sh/tangled.sh/core/appview/pages"
24
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
25
"tangled.sh/tangled.sh/core/appview/pagination"
26
26
"tangled.sh/tangled.sh/core/appview/reporesolver"
27
+
"tangled.sh/tangled.sh/core/appview/validator"
28
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
27
29
"tangled.sh/tangled.sh/core/idresolver"
30
+
tlog "tangled.sh/tangled.sh/core/log"
28
31
"tangled.sh/tangled.sh/core/tid"
29
32
)
30
33
···
36
39
db *db.DB
37
40
config *config.Config
38
41
notifier notify.Notifier
42
+
logger *slog.Logger
43
+
validator *validator.Validator
39
44
}
40
45
41
46
func New(
···
46
51
db *db.DB,
47
52
config *config.Config,
48
53
notifier notify.Notifier,
54
+
validator *validator.Validator,
49
55
) *Issues {
50
56
return &Issues{
51
57
oauth: oauth,
···
55
61
db: db,
56
62
config: config,
57
63
notifier: notifier,
64
+
logger: tlog.New("issues"),
65
+
validator: validator,
58
66
}
59
67
}
60
68
61
69
func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
70
+
l := rp.logger.With("handler", "RepoSingleIssue")
62
71
user := rp.oauth.GetUser(r)
63
72
f, err := rp.repoResolver.Resolve(r)
64
73
if err != nil {
···
66
75
return
67
76
}
68
77
69
-
issueId := chi.URLParam(r, "issue")
70
-
issueIdInt, err := strconv.Atoi(issueId)
71
-
if err != nil {
72
-
http.Error(w, "bad issue id", http.StatusBadRequest)
73
-
log.Println("failed to parse issue id", err)
74
-
return
75
-
}
76
-
77
-
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt)
78
-
if err != nil {
79
-
log.Println("failed to get issue and comments", err)
80
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
78
+
issue, ok := r.Context().Value("issue").(*db.Issue)
79
+
if !ok {
80
+
l.Error("failed to get issue")
81
+
rp.pages.Error404(w)
81
82
return
82
83
}
83
84
84
85
reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
85
86
if err != nil {
86
-
log.Println("failed to get issue reactions")
87
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
87
+
l.Error("failed to get issue reactions", "err", err)
88
88
}
89
89
90
90
userReactions := map[db.ReactionKind]bool{}
···
92
92
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
93
93
}
94
94
95
-
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
96
-
if err != nil {
97
-
log.Println("failed to resolve issue owner", err)
98
-
}
99
-
100
95
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
101
-
LoggedInUser: user,
102
-
RepoInfo: f.RepoInfo(user),
103
-
Issue: issue,
104
-
Comments: comments,
105
-
106
-
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
107
-
96
+
LoggedInUser: user,
97
+
RepoInfo: f.RepoInfo(user),
98
+
Issue: issue,
99
+
CommentList: issue.CommentList(),
108
100
OrderedReactionKinds: db.OrderedReactionKinds,
109
101
Reactions: reactionCountMap,
110
102
UserReacted: userReactions,
111
103
})
112
-
113
104
}
114
105
115
-
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
106
+
func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
107
+
l := rp.logger.With("handler", "EditIssue")
116
108
user := rp.oauth.GetUser(r)
117
109
f, err := rp.repoResolver.Resolve(r)
118
110
if err != nil {
···
120
112
return
121
113
}
122
114
123
-
issueId := chi.URLParam(r, "issue")
124
-
issueIdInt, err := strconv.Atoi(issueId)
125
-
if err != nil {
126
-
http.Error(w, "bad issue id", http.StatusBadRequest)
127
-
log.Println("failed to parse issue id", err)
115
+
issue, ok := r.Context().Value("issue").(*db.Issue)
116
+
if !ok {
117
+
l.Error("failed to get issue")
118
+
rp.pages.Error404(w)
128
119
return
129
120
}
130
121
131
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
132
-
if err != nil {
133
-
log.Println("failed to get issue", err)
134
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
135
-
return
136
-
}
122
+
switch r.Method {
123
+
case http.MethodGet:
124
+
rp.pages.EditIssueFragment(w, pages.EditIssueParams{
125
+
LoggedInUser: user,
126
+
RepoInfo: f.RepoInfo(user),
127
+
Issue: issue,
128
+
})
129
+
case http.MethodPost:
130
+
noticeId := "issues"
131
+
newIssue := issue
132
+
newIssue.Title = r.FormValue("title")
133
+
newIssue.Body = r.FormValue("body")
137
134
138
-
collaborators, err := f.Collaborators(r.Context())
139
-
if err != nil {
140
-
log.Println("failed to fetch repo collaborators: %w", err)
141
-
}
142
-
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
143
-
return user.Did == collab.Did
144
-
})
145
-
isIssueOwner := user.Did == issue.OwnerDid
146
-
147
-
// TODO: make this more granular
148
-
if isIssueOwner || isCollaborator {
135
+
if err := rp.validator.ValidateIssue(newIssue); err != nil {
136
+
l.Error("validation error", "err", err)
137
+
rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err))
138
+
return
139
+
}
149
140
150
-
closed := tangled.RepoIssueStateClosed
141
+
newRecord := newIssue.AsRecord()
151
142
143
+
// edit an atproto record
152
144
client, err := rp.oauth.AuthorizedClient(r)
153
145
if err != nil {
154
-
log.Println("failed to get authorized client", err)
146
+
l.Error("failed to get authorized client", "err", err)
147
+
rp.pages.Notice(w, noticeId, "Failed to edit issue.")
155
148
return
156
149
}
150
+
151
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
152
+
if err != nil {
153
+
l.Error("failed to get record", "err", err)
154
+
rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
155
+
return
156
+
}
157
+
157
158
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
158
-
Collection: tangled.RepoIssueStateNSID,
159
+
Collection: tangled.RepoIssueNSID,
159
160
Repo: user.Did,
160
-
Rkey: tid.TID(),
161
+
Rkey: newIssue.Rkey,
162
+
SwapRecord: ex.Cid,
161
163
Record: &lexutil.LexiconTypeDecoder{
162
-
Val: &tangled.RepoIssueState{
163
-
Issue: issue.AtUri().String(),
164
-
State: closed,
165
-
},
164
+
Val: &newRecord,
166
165
},
167
166
})
167
+
if err != nil {
168
+
l.Error("failed to edit record on PDS", "err", err)
169
+
rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.")
170
+
return
171
+
}
168
172
173
+
// modify on DB -- TODO: transact this cleverly
174
+
tx, err := rp.db.Begin()
169
175
if err != nil {
170
-
log.Println("failed to update issue state", err)
171
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
176
+
l.Error("failed to edit issue on DB", "err", err)
177
+
rp.pages.Notice(w, noticeId, "Failed to edit issue.")
178
+
return
179
+
}
180
+
defer tx.Rollback()
181
+
182
+
err = db.PutIssue(tx, newIssue)
183
+
if err != nil {
184
+
log.Println("failed to edit issue", err)
185
+
rp.pages.Notice(w, "issues", "Failed to edit issue.")
186
+
return
187
+
}
188
+
189
+
if err = tx.Commit(); err != nil {
190
+
l.Error("failed to edit issue", "err", err)
191
+
rp.pages.Notice(w, "issues", "Failed to cedit issue.")
172
192
return
173
193
}
174
194
175
-
err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt)
195
+
rp.pages.HxRefresh(w)
196
+
}
197
+
}
198
+
199
+
func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) {
200
+
l := rp.logger.With("handler", "DeleteIssue")
201
+
noticeId := "issue-actions-error"
202
+
203
+
user := rp.oauth.GetUser(r)
204
+
205
+
f, err := rp.repoResolver.Resolve(r)
206
+
if err != nil {
207
+
l.Error("failed to get repo and knot", "err", err)
208
+
return
209
+
}
210
+
211
+
issue, ok := r.Context().Value("issue").(*db.Issue)
212
+
if !ok {
213
+
l.Error("failed to get issue")
214
+
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
215
+
return
216
+
}
217
+
l = l.With("did", issue.Did, "rkey", issue.Rkey)
218
+
219
+
// delete from PDS
220
+
client, err := rp.oauth.AuthorizedClient(r)
221
+
if err != nil {
222
+
log.Println("failed to get authorized client", err)
223
+
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
224
+
return
225
+
}
226
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
227
+
Collection: tangled.RepoIssueNSID,
228
+
Repo: issue.Did,
229
+
Rkey: issue.Rkey,
230
+
})
231
+
if err != nil {
232
+
// TODO: transact this better
233
+
l.Error("failed to delete issue from PDS", "err", err)
234
+
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
235
+
return
236
+
}
237
+
238
+
// delete from db
239
+
if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil {
240
+
l.Error("failed to delete issue", "err", err)
241
+
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
242
+
return
243
+
}
244
+
245
+
// return to all issues page
246
+
rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
247
+
}
248
+
249
+
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
250
+
l := rp.logger.With("handler", "CloseIssue")
251
+
user := rp.oauth.GetUser(r)
252
+
f, err := rp.repoResolver.Resolve(r)
253
+
if err != nil {
254
+
l.Error("failed to get repo and knot", "err", err)
255
+
return
256
+
}
257
+
258
+
issue, ok := r.Context().Value("issue").(*db.Issue)
259
+
if !ok {
260
+
l.Error("failed to get issue")
261
+
rp.pages.Error404(w)
262
+
return
263
+
}
264
+
265
+
collaborators, err := f.Collaborators(r.Context())
266
+
if err != nil {
267
+
log.Println("failed to fetch repo collaborators: %w", err)
268
+
}
269
+
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
270
+
return user.Did == collab.Did
271
+
})
272
+
isIssueOwner := user.Did == issue.Did
273
+
274
+
// TODO: make this more granular
275
+
if isIssueOwner || isCollaborator {
276
+
err = db.CloseIssues(
277
+
rp.db,
278
+
db.FilterEq("id", issue.Id),
279
+
)
176
280
if err != nil {
177
281
log.Println("failed to close issue", err)
178
282
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
179
283
return
180
284
}
181
285
182
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
286
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
183
287
return
184
288
} else {
185
289
log.Println("user is not permitted to close issue")
···
189
293
}
190
294
191
295
func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) {
296
+
l := rp.logger.With("handler", "ReopenIssue")
192
297
user := rp.oauth.GetUser(r)
193
298
f, err := rp.repoResolver.Resolve(r)
194
299
if err != nil {
···
196
301
return
197
302
}
198
303
199
-
issueId := chi.URLParam(r, "issue")
200
-
issueIdInt, err := strconv.Atoi(issueId)
201
-
if err != nil {
202
-
http.Error(w, "bad issue id", http.StatusBadRequest)
203
-
log.Println("failed to parse issue id", err)
204
-
return
205
-
}
206
-
207
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
208
-
if err != nil {
209
-
log.Println("failed to get issue", err)
210
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
304
+
issue, ok := r.Context().Value("issue").(*db.Issue)
305
+
if !ok {
306
+
l.Error("failed to get issue")
307
+
rp.pages.Error404(w)
211
308
return
212
309
}
213
310
···
218
315
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
219
316
return user.Did == collab.Did
220
317
})
221
-
isIssueOwner := user.Did == issue.OwnerDid
318
+
isIssueOwner := user.Did == issue.Did
222
319
223
320
if isCollaborator || isIssueOwner {
224
-
err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt)
321
+
err := db.ReopenIssues(
322
+
rp.db,
323
+
db.FilterEq("id", issue.Id),
324
+
)
225
325
if err != nil {
226
326
log.Println("failed to reopen issue", err)
227
327
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
228
328
return
229
329
}
230
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
330
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
231
331
return
232
332
} else {
233
333
log.Println("user is not the owner of the repo")
···
237
337
}
238
338
239
339
func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) {
340
+
l := rp.logger.With("handler", "NewIssueComment")
240
341
user := rp.oauth.GetUser(r)
241
342
f, err := rp.repoResolver.Resolve(r)
242
343
if err != nil {
243
-
log.Println("failed to get repo and knot", err)
344
+
l.Error("failed to get repo and knot", "err", err)
244
345
return
245
346
}
246
347
247
-
issueId := chi.URLParam(r, "issue")
248
-
issueIdInt, err := strconv.Atoi(issueId)
249
-
if err != nil {
250
-
http.Error(w, "bad issue id", http.StatusBadRequest)
251
-
log.Println("failed to parse issue id", err)
348
+
issue, ok := r.Context().Value("issue").(*db.Issue)
349
+
if !ok {
350
+
l.Error("failed to get issue")
351
+
rp.pages.Error404(w)
252
352
return
253
353
}
254
354
255
-
switch r.Method {
256
-
case http.MethodPost:
257
-
body := r.FormValue("body")
258
-
if body == "" {
259
-
rp.pages.Notice(w, "issue", "Body is required")
260
-
return
261
-
}
355
+
body := r.FormValue("body")
356
+
if body == "" {
357
+
rp.pages.Notice(w, "issue", "Body is required")
358
+
return
359
+
}
262
360
263
-
commentId := mathrand.IntN(1000000)
264
-
rkey := tid.TID()
361
+
replyToUri := r.FormValue("reply-to")
362
+
var replyTo *string
363
+
if replyToUri != "" {
364
+
replyTo = &replyToUri
365
+
}
265
366
266
-
err := db.NewIssueComment(rp.db, &db.Comment{
267
-
OwnerDid: user.Did,
268
-
RepoAt: f.RepoAt(),
269
-
Issue: issueIdInt,
270
-
CommentId: commentId,
271
-
Body: body,
272
-
Rkey: rkey,
273
-
})
274
-
if err != nil {
275
-
log.Println("failed to create comment", err)
276
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
277
-
return
278
-
}
367
+
comment := db.IssueComment{
368
+
Did: user.Did,
369
+
Rkey: tid.TID(),
370
+
IssueAt: issue.AtUri().String(),
371
+
ReplyTo: replyTo,
372
+
Body: body,
373
+
Created: time.Now(),
374
+
}
375
+
if err = rp.validator.ValidateIssueComment(&comment); err != nil {
376
+
l.Error("failed to validate comment", "err", err)
377
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
378
+
return
379
+
}
380
+
record := comment.AsRecord()
279
381
280
-
createdAt := time.Now().Format(time.RFC3339)
281
-
ownerDid := user.Did
282
-
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt)
283
-
if err != nil {
284
-
log.Println("failed to get issue at", err)
285
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
286
-
return
287
-
}
382
+
client, err := rp.oauth.AuthorizedClient(r)
383
+
if err != nil {
384
+
l.Error("failed to get authorized client", "err", err)
385
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
386
+
return
387
+
}
288
388
289
-
atUri := f.RepoAt().String()
290
-
client, err := rp.oauth.AuthorizedClient(r)
291
-
if err != nil {
292
-
log.Println("failed to get authorized client", err)
293
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
294
-
return
389
+
// create a record first
390
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
391
+
Collection: tangled.RepoIssueCommentNSID,
392
+
Repo: comment.Did,
393
+
Rkey: comment.Rkey,
394
+
Record: &lexutil.LexiconTypeDecoder{
395
+
Val: &record,
396
+
},
397
+
})
398
+
if err != nil {
399
+
l.Error("failed to create comment", "err", err)
400
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
401
+
return
402
+
}
403
+
atUri := resp.Uri
404
+
defer func() {
405
+
if err := rollbackRecord(context.Background(), atUri, client); err != nil {
406
+
l.Error("rollback failed", "err", err)
295
407
}
296
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
297
-
Collection: tangled.RepoIssueCommentNSID,
298
-
Repo: user.Did,
299
-
Rkey: rkey,
300
-
Record: &lexutil.LexiconTypeDecoder{
301
-
Val: &tangled.RepoIssueComment{
302
-
Repo: &atUri,
303
-
Issue: issueAt,
304
-
Owner: &ownerDid,
305
-
Body: body,
306
-
CreatedAt: createdAt,
307
-
},
308
-
},
309
-
})
310
-
if err != nil {
311
-
log.Println("failed to create comment", err)
312
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
313
-
return
314
-
}
408
+
}()
315
409
316
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
410
+
commentId, err := db.AddIssueComment(rp.db, comment)
411
+
if err != nil {
412
+
l.Error("failed to create comment", "err", err)
413
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
317
414
return
318
415
}
416
+
417
+
// reset atUri to make rollback a no-op
418
+
atUri = ""
419
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
319
420
}
320
421
321
422
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
423
+
l := rp.logger.With("handler", "IssueComment")
322
424
user := rp.oauth.GetUser(r)
323
425
f, err := rp.repoResolver.Resolve(r)
324
426
if err != nil {
325
-
log.Println("failed to get repo and knot", err)
427
+
l.Error("failed to get repo and knot", "err", err)
326
428
return
327
429
}
328
430
329
-
issueId := chi.URLParam(r, "issue")
330
-
issueIdInt, err := strconv.Atoi(issueId)
331
-
if err != nil {
332
-
http.Error(w, "bad issue id", http.StatusBadRequest)
333
-
log.Println("failed to parse issue id", err)
431
+
issue, ok := r.Context().Value("issue").(*db.Issue)
432
+
if !ok {
433
+
l.Error("failed to get issue")
434
+
rp.pages.Error404(w)
334
435
return
335
436
}
336
437
337
-
commentId := chi.URLParam(r, "comment_id")
338
-
commentIdInt, err := strconv.Atoi(commentId)
438
+
commentId := chi.URLParam(r, "commentId")
439
+
comments, err := db.GetIssueComments(
440
+
rp.db,
441
+
db.FilterEq("id", commentId),
442
+
)
339
443
if err != nil {
340
-
http.Error(w, "bad comment id", http.StatusBadRequest)
341
-
log.Println("failed to parse issue id", err)
444
+
l.Error("failed to fetch comment", "id", commentId)
445
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
342
446
return
343
447
}
344
-
345
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
346
-
if err != nil {
347
-
log.Println("failed to get issue", err)
348
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
448
+
if len(comments) != 1 {
449
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
450
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
349
451
return
350
452
}
453
+
comment := comments[0]
351
454
352
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
353
-
if err != nil {
354
-
http.Error(w, "bad comment id", http.StatusBadRequest)
355
-
return
356
-
}
357
-
358
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
455
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
359
456
LoggedInUser: user,
360
457
RepoInfo: f.RepoInfo(user),
361
458
Issue: issue,
362
-
Comment: comment,
459
+
Comment: &comment,
363
460
})
364
461
}
365
462
366
463
func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
464
+
l := rp.logger.With("handler", "EditIssueComment")
367
465
user := rp.oauth.GetUser(r)
368
466
f, err := rp.repoResolver.Resolve(r)
369
467
if err != nil {
370
-
log.Println("failed to get repo and knot", err)
468
+
l.Error("failed to get repo and knot", "err", err)
371
469
return
372
470
}
373
471
374
-
issueId := chi.URLParam(r, "issue")
375
-
issueIdInt, err := strconv.Atoi(issueId)
376
-
if err != nil {
377
-
http.Error(w, "bad issue id", http.StatusBadRequest)
378
-
log.Println("failed to parse issue id", err)
472
+
issue, ok := r.Context().Value("issue").(*db.Issue)
473
+
if !ok {
474
+
l.Error("failed to get issue")
475
+
rp.pages.Error404(w)
379
476
return
380
477
}
381
478
382
-
commentId := chi.URLParam(r, "comment_id")
383
-
commentIdInt, err := strconv.Atoi(commentId)
479
+
commentId := chi.URLParam(r, "commentId")
480
+
comments, err := db.GetIssueComments(
481
+
rp.db,
482
+
db.FilterEq("id", commentId),
483
+
)
384
484
if err != nil {
385
-
http.Error(w, "bad comment id", http.StatusBadRequest)
386
-
log.Println("failed to parse issue id", err)
485
+
l.Error("failed to fetch comment", "id", commentId)
486
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
387
487
return
388
488
}
389
-
390
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
391
-
if err != nil {
392
-
log.Println("failed to get issue", err)
393
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
489
+
if len(comments) != 1 {
490
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
491
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
394
492
return
395
493
}
494
+
comment := comments[0]
396
495
397
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
398
-
if err != nil {
399
-
http.Error(w, "bad comment id", http.StatusBadRequest)
400
-
return
401
-
}
402
-
403
-
if comment.OwnerDid != user.Did {
496
+
if comment.Did != user.Did {
497
+
l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did)
404
498
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
405
499
return
406
500
}
···
411
505
LoggedInUser: user,
412
506
RepoInfo: f.RepoInfo(user),
413
507
Issue: issue,
414
-
Comment: comment,
508
+
Comment: &comment,
415
509
})
416
510
case http.MethodPost:
417
511
// extract form value
···
422
516
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
423
517
return
424
518
}
425
-
rkey := comment.Rkey
426
519
427
-
// optimistic update
428
-
edited := time.Now()
429
-
err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
520
+
now := time.Now()
521
+
newComment := comment
522
+
newComment.Body = newBody
523
+
newComment.Edited = &now
524
+
record := newComment.AsRecord()
525
+
526
+
_, err = db.AddIssueComment(rp.db, newComment)
430
527
if err != nil {
431
528
log.Println("failed to perferom update-description query", err)
432
529
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
···
434
531
}
435
532
436
533
// rkey is optional, it was introduced later
437
-
if comment.Rkey != "" {
534
+
if newComment.Rkey != "" {
438
535
// update the record on pds
439
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
536
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
440
537
if err != nil {
441
-
// failed to get record
442
-
log.Println(err, rkey)
538
+
log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
443
539
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
444
540
return
445
541
}
446
-
value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
447
-
record, _ := data.UnmarshalJSON(value)
448
-
449
-
repoAt := record["repo"].(string)
450
-
issueAt := record["issue"].(string)
451
-
createdAt := record["createdAt"].(string)
452
542
453
543
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
454
544
Collection: tangled.RepoIssueCommentNSID,
455
545
Repo: user.Did,
456
-
Rkey: rkey,
546
+
Rkey: newComment.Rkey,
457
547
SwapRecord: ex.Cid,
458
548
Record: &lexutil.LexiconTypeDecoder{
459
-
Val: &tangled.RepoIssueComment{
460
-
Repo: &repoAt,
461
-
Issue: issueAt,
462
-
Owner: &comment.OwnerDid,
463
-
Body: newBody,
464
-
CreatedAt: createdAt,
465
-
},
549
+
Val: &record,
466
550
},
467
551
})
468
552
if err != nil {
469
-
log.Println(err)
553
+
l.Error("failed to update record on PDS", "err", err)
470
554
}
471
555
}
472
556
473
-
// optimistic update for htmx
474
-
comment.Body = newBody
475
-
comment.Edited = &edited
476
-
477
557
// return new comment body with htmx
478
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
558
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
479
559
LoggedInUser: user,
480
560
RepoInfo: f.RepoInfo(user),
481
561
Issue: issue,
482
-
Comment: comment,
562
+
Comment: &newComment,
483
563
})
564
+
}
565
+
}
566
+
567
+
func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
568
+
l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
569
+
user := rp.oauth.GetUser(r)
570
+
f, err := rp.repoResolver.Resolve(r)
571
+
if err != nil {
572
+
l.Error("failed to get repo and knot", "err", err)
484
573
return
574
+
}
485
575
576
+
issue, ok := r.Context().Value("issue").(*db.Issue)
577
+
if !ok {
578
+
l.Error("failed to get issue")
579
+
rp.pages.Error404(w)
580
+
return
486
581
}
487
582
583
+
commentId := chi.URLParam(r, "commentId")
584
+
comments, err := db.GetIssueComments(
585
+
rp.db,
586
+
db.FilterEq("id", commentId),
587
+
)
588
+
if err != nil {
589
+
l.Error("failed to fetch comment", "id", commentId)
590
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
591
+
return
592
+
}
593
+
if len(comments) != 1 {
594
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
595
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
596
+
return
597
+
}
598
+
comment := comments[0]
599
+
600
+
rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{
601
+
LoggedInUser: user,
602
+
RepoInfo: f.RepoInfo(user),
603
+
Issue: issue,
604
+
Comment: &comment,
605
+
})
488
606
}
489
607
490
-
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
608
+
func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) {
609
+
l := rp.logger.With("handler", "ReplyIssueComment")
491
610
user := rp.oauth.GetUser(r)
492
611
f, err := rp.repoResolver.Resolve(r)
493
612
if err != nil {
494
-
log.Println("failed to get repo and knot", err)
613
+
l.Error("failed to get repo and knot", "err", err)
495
614
return
496
615
}
497
616
498
-
issueId := chi.URLParam(r, "issue")
499
-
issueIdInt, err := strconv.Atoi(issueId)
500
-
if err != nil {
501
-
http.Error(w, "bad issue id", http.StatusBadRequest)
502
-
log.Println("failed to parse issue id", err)
617
+
issue, ok := r.Context().Value("issue").(*db.Issue)
618
+
if !ok {
619
+
l.Error("failed to get issue")
620
+
rp.pages.Error404(w)
503
621
return
504
622
}
505
623
506
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
624
+
commentId := chi.URLParam(r, "commentId")
625
+
comments, err := db.GetIssueComments(
626
+
rp.db,
627
+
db.FilterEq("id", commentId),
628
+
)
507
629
if err != nil {
508
-
log.Println("failed to get issue", err)
509
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
630
+
l.Error("failed to fetch comment", "id", commentId)
631
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
510
632
return
511
633
}
634
+
if len(comments) != 1 {
635
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
636
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
637
+
return
638
+
}
639
+
comment := comments[0]
512
640
513
-
commentId := chi.URLParam(r, "comment_id")
514
-
commentIdInt, err := strconv.Atoi(commentId)
641
+
rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{
642
+
LoggedInUser: user,
643
+
RepoInfo: f.RepoInfo(user),
644
+
Issue: issue,
645
+
Comment: &comment,
646
+
})
647
+
}
648
+
649
+
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
650
+
l := rp.logger.With("handler", "DeleteIssueComment")
651
+
user := rp.oauth.GetUser(r)
652
+
f, err := rp.repoResolver.Resolve(r)
515
653
if err != nil {
516
-
http.Error(w, "bad comment id", http.StatusBadRequest)
517
-
log.Println("failed to parse issue id", err)
654
+
l.Error("failed to get repo and knot", "err", err)
518
655
return
519
656
}
520
657
521
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
658
+
issue, ok := r.Context().Value("issue").(*db.Issue)
659
+
if !ok {
660
+
l.Error("failed to get issue")
661
+
rp.pages.Error404(w)
662
+
return
663
+
}
664
+
665
+
commentId := chi.URLParam(r, "commentId")
666
+
comments, err := db.GetIssueComments(
667
+
rp.db,
668
+
db.FilterEq("id", commentId),
669
+
)
522
670
if err != nil {
523
-
http.Error(w, "bad comment id", http.StatusBadRequest)
671
+
l.Error("failed to fetch comment", "id", commentId)
672
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
524
673
return
525
674
}
675
+
if len(comments) != 1 {
676
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
677
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
678
+
return
679
+
}
680
+
comment := comments[0]
526
681
527
-
if comment.OwnerDid != user.Did {
682
+
if comment.Did != user.Did {
683
+
l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did)
528
684
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
529
685
return
530
686
}
···
536
692
537
693
// optimistic deletion
538
694
deleted := time.Now()
539
-
err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
695
+
err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id))
540
696
if err != nil {
541
-
log.Println("failed to delete comment")
697
+
l.Error("failed to delete comment", "err", err)
542
698
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
543
699
return
544
700
}
···
552
708
return
553
709
}
554
710
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
555
-
Collection: tangled.GraphFollowNSID,
711
+
Collection: tangled.RepoIssueCommentNSID,
556
712
Repo: user.Did,
557
713
Rkey: comment.Rkey,
558
714
})
···
566
722
comment.Deleted = &deleted
567
723
568
724
// htmx fragment of comment after deletion
569
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
725
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
570
726
LoggedInUser: user,
571
727
RepoInfo: f.RepoInfo(user),
572
728
Issue: issue,
573
-
Comment: comment,
729
+
Comment: &comment,
574
730
})
575
731
}
576
732
···
600
756
return
601
757
}
602
758
603
-
issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page)
759
+
openVal := 0
760
+
if isOpen {
761
+
openVal = 1
762
+
}
763
+
issues, err := db.GetIssuesPaginated(
764
+
rp.db,
765
+
page,
766
+
db.FilterEq("repo_at", f.RepoAt()),
767
+
db.FilterEq("open", openVal),
768
+
)
604
769
if err != nil {
605
770
log.Println("failed to get issues", err)
606
771
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
617
782
}
618
783
619
784
func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
785
+
l := rp.logger.With("handler", "NewIssue")
620
786
user := rp.oauth.GetUser(r)
621
787
622
788
f, err := rp.repoResolver.Resolve(r)
623
789
if err != nil {
624
-
log.Println("failed to get repo and knot", err)
790
+
l.Error("failed to get repo and knot", "err", err)
625
791
return
626
792
}
627
793
···
632
798
RepoInfo: f.RepoInfo(user),
633
799
})
634
800
case http.MethodPost:
635
-
title := r.FormValue("title")
636
-
body := r.FormValue("body")
801
+
issue := &db.Issue{
802
+
RepoAt: f.RepoAt(),
803
+
Rkey: tid.TID(),
804
+
Title: r.FormValue("title"),
805
+
Body: r.FormValue("body"),
806
+
Did: user.Did,
807
+
Created: time.Now(),
808
+
}
637
809
638
-
if title == "" || body == "" {
639
-
rp.pages.Notice(w, "issues", "Title and body are required")
810
+
if err := rp.validator.ValidateIssue(issue); err != nil {
811
+
l.Error("validation error", "err", err)
812
+
rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err))
640
813
return
641
814
}
642
815
643
-
sanitizer := markup.NewSanitizer()
644
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" {
645
-
rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization")
816
+
record := issue.AsRecord()
817
+
818
+
// create an atproto record
819
+
client, err := rp.oauth.AuthorizedClient(r)
820
+
if err != nil {
821
+
l.Error("failed to get authorized client", "err", err)
822
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
646
823
return
647
824
}
648
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
649
-
rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization")
825
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
826
+
Collection: tangled.RepoIssueNSID,
827
+
Repo: user.Did,
828
+
Rkey: issue.Rkey,
829
+
Record: &lexutil.LexiconTypeDecoder{
830
+
Val: &record,
831
+
},
832
+
})
833
+
if err != nil {
834
+
l.Error("failed to create issue", "err", err)
835
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
650
836
return
651
837
}
838
+
atUri := resp.Uri
652
839
653
840
tx, err := rp.db.BeginTx(r.Context(), nil)
654
841
if err != nil {
655
842
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
656
843
return
657
844
}
845
+
rollback := func() {
846
+
err1 := tx.Rollback()
847
+
err2 := rollbackRecord(context.Background(), atUri, client)
658
848
659
-
issue := &db.Issue{
660
-
RepoAt: f.RepoAt(),
661
-
Rkey: tid.TID(),
662
-
Title: title,
663
-
Body: body,
664
-
OwnerDid: user.Did,
849
+
if errors.Is(err1, sql.ErrTxDone) {
850
+
err1 = nil
851
+
}
852
+
853
+
if err := errors.Join(err1, err2); err != nil {
854
+
l.Error("failed to rollback txn", "err", err)
855
+
}
665
856
}
666
-
err = db.NewIssue(tx, issue)
857
+
defer rollback()
858
+
859
+
err = db.PutIssue(tx, issue)
667
860
if err != nil {
668
861
log.Println("failed to create issue", err)
669
862
rp.pages.Notice(w, "issues", "Failed to create issue.")
670
863
return
671
864
}
672
865
673
-
client, err := rp.oauth.AuthorizedClient(r)
674
-
if err != nil {
675
-
log.Println("failed to get authorized client", err)
676
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
677
-
return
678
-
}
679
-
atUri := f.RepoAt().String()
680
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
681
-
Collection: tangled.RepoIssueNSID,
682
-
Repo: user.Did,
683
-
Rkey: issue.Rkey,
684
-
Record: &lexutil.LexiconTypeDecoder{
685
-
Val: &tangled.RepoIssue{
686
-
Repo: atUri,
687
-
Title: title,
688
-
Body: &body,
689
-
},
690
-
},
691
-
})
692
-
if err != nil {
866
+
if err = tx.Commit(); err != nil {
693
867
log.Println("failed to create issue", err)
694
868
rp.pages.Notice(w, "issues", "Failed to create issue.")
695
869
return
696
870
}
697
871
872
+
// everything is successful, do not rollback the atproto record
873
+
atUri = ""
698
874
rp.notifier.NewIssue(r.Context(), issue)
699
-
700
875
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
701
876
return
702
877
}
703
878
}
879
+
880
+
// this is used to rollback changes made to the PDS
881
+
//
882
+
// it is a no-op if the provided ATURI is empty
883
+
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
884
+
if aturi == "" {
885
+
return nil
886
+
}
887
+
888
+
parsed := syntax.ATURI(aturi)
889
+
890
+
collection := parsed.Collection().String()
891
+
repo := parsed.Authority().String()
892
+
rkey := parsed.RecordKey().String()
893
+
894
+
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
895
+
Collection: collection,
896
+
Repo: repo,
897
+
Rkey: rkey,
898
+
})
899
+
return err
900
+
}
+24
-10
appview/issues/router.go
+24
-10
appview/issues/router.go
···
12
12
13
13
r.Route("/", func(r chi.Router) {
14
14
r.With(middleware.Paginate).Get("/", i.RepoIssues)
15
-
r.Get("/{issue}", i.RepoSingleIssue)
15
+
16
+
r.Route("/{issue}", func(r chi.Router) {
17
+
r.Use(mw.ResolveIssue())
18
+
r.Get("/", i.RepoSingleIssue)
19
+
20
+
// authenticated routes
21
+
r.Group(func(r chi.Router) {
22
+
r.Use(middleware.AuthMiddleware(i.oauth))
23
+
r.Post("/comment", i.NewIssueComment)
24
+
r.Route("/comment/{commentId}/", func(r chi.Router) {
25
+
r.Get("/", i.IssueComment)
26
+
r.Delete("/", i.DeleteIssueComment)
27
+
r.Get("/edit", i.EditIssueComment)
28
+
r.Post("/edit", i.EditIssueComment)
29
+
r.Get("/reply", i.ReplyIssueComment)
30
+
r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder)
31
+
})
32
+
r.Get("/edit", i.EditIssue)
33
+
r.Post("/edit", i.EditIssue)
34
+
r.Delete("/", i.DeleteIssue)
35
+
r.Post("/close", i.CloseIssue)
36
+
r.Post("/reopen", i.ReopenIssue)
37
+
})
38
+
})
16
39
17
40
r.Group(func(r chi.Router) {
18
41
r.Use(middleware.AuthMiddleware(i.oauth))
19
42
r.Get("/new", i.NewIssue)
20
43
r.Post("/new", i.NewIssue)
21
-
r.Post("/{issue}/comment", i.NewIssueComment)
22
-
r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) {
23
-
r.Get("/", i.IssueComment)
24
-
r.Delete("/", i.DeleteIssueComment)
25
-
r.Get("/edit", i.EditIssueComment)
26
-
r.Post("/edit", i.EditIssueComment)
27
-
})
28
-
r.Post("/{issue}/close", i.CloseIssue)
29
-
r.Post("/{issue}/reopen", i.ReopenIssue)
30
44
})
31
45
})
32
46
+5
-34
appview/knots/knots.go
+5
-34
appview/knots/knots.go
···
3
3
import (
4
4
"errors"
5
5
"fmt"
6
-
"log"
7
6
"log/slog"
8
7
"net/http"
9
8
"slices"
···
17
16
"tangled.sh/tangled.sh/core/appview/oauth"
18
17
"tangled.sh/tangled.sh/core/appview/pages"
19
18
"tangled.sh/tangled.sh/core/appview/serververify"
19
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
20
"tangled.sh/tangled.sh/core/eventconsumer"
21
21
"tangled.sh/tangled.sh/core/idresolver"
22
22
"tangled.sh/tangled.sh/core/rbac"
···
49
49
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry)
50
50
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember)
51
51
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember)
52
-
53
-
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner)
54
52
55
53
return r
56
54
}
···
399
397
if err != nil {
400
398
l.Error("verification failed", "err", err)
401
399
402
-
if errors.Is(err, serververify.FetchError) {
403
-
k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.")
400
+
if errors.Is(err, xrpcclient.ErrXrpcUnsupported) {
401
+
k.Pages.Notice(w, noticeId, "Failed to verify knot, XRPC queries are unsupported on this knot, consider upgrading!")
404
402
return
405
403
}
406
404
···
420
418
return
421
419
}
422
420
423
-
// if this knot was previously read-only, then emit a record too
421
+
// if this knot requires upgrade, then emit a record too
424
422
//
425
423
// this is part of migrating from the old knot system to the new one
426
-
if registration.ReadOnly {
424
+
if registration.NeedsUpgrade {
427
425
// re-announce by registering under same rkey
428
426
client, err := k.OAuth.AuthorizedClient(r)
429
427
if err != nil {
···
484
482
return
485
483
}
486
484
updatedRegistration := registrations[0]
487
-
488
-
log.Println(updatedRegistration)
489
485
490
486
w.Header().Set("HX-Reswap", "outerHTML")
491
487
k.Pages.KnotListing(w, pages.KnotListingParams{
···
678
674
// ok
679
675
k.Pages.HxRefresh(w)
680
676
}
681
-
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
-
})
705
-
}
+40
appview/middleware/middleware.go
+40
appview/middleware/middleware.go
···
275
275
}
276
276
}
277
277
278
+
// middleware that is tacked on top of /{user}/{repo}/issues/{issue}
279
+
func (mw Middleware) ResolveIssue() middlewareFunc {
280
+
return func(next http.Handler) http.Handler {
281
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
282
+
f, err := mw.repoResolver.Resolve(r)
283
+
if err != nil {
284
+
log.Println("failed to fully resolve repo", err)
285
+
mw.pages.ErrorKnot404(w)
286
+
return
287
+
}
288
+
289
+
issueIdStr := chi.URLParam(r, "issue")
290
+
issueId, err := strconv.Atoi(issueIdStr)
291
+
if err != nil {
292
+
log.Println("failed to fully resolve issue ID", err)
293
+
mw.pages.ErrorKnot404(w)
294
+
return
295
+
}
296
+
297
+
issues, err := db.GetIssues(
298
+
mw.db,
299
+
db.FilterEq("repo_at", f.RepoAt()),
300
+
db.FilterEq("issue_id", issueId),
301
+
)
302
+
if err != nil {
303
+
log.Println("failed to get issues", "err", err)
304
+
return
305
+
}
306
+
if len(issues) != 1 {
307
+
log.Println("got incorrect number of issues", "len(issuse)", len(issues))
308
+
return
309
+
}
310
+
issue := issues[0]
311
+
312
+
ctx := context.WithValue(r.Context(), "issue", &issue)
313
+
next.ServeHTTP(w, r.WithContext(ctx))
314
+
})
315
+
}
316
+
}
317
+
278
318
// this should serve the go-import meta tag even if the path is technically
279
319
// a 404 like tangled.sh/oppi.li/go-git/v5
280
320
func (mw Middleware) GoImport() middlewareFunc {
+35
appview/pages/cache.go
+35
appview/pages/cache.go
···
1
+
package pages
2
+
3
+
import (
4
+
"sync"
5
+
)
6
+
7
+
type TmplCache[K comparable, V any] struct {
8
+
data map[K]V
9
+
mutex sync.RWMutex
10
+
}
11
+
12
+
func NewTmplCache[K comparable, V any]() *TmplCache[K, V] {
13
+
return &TmplCache[K, V]{
14
+
data: make(map[K]V),
15
+
}
16
+
}
17
+
18
+
func (c *TmplCache[K, V]) Get(key K) (V, bool) {
19
+
c.mutex.RLock()
20
+
defer c.mutex.RUnlock()
21
+
val, exists := c.data[key]
22
+
return val, exists
23
+
}
24
+
25
+
func (c *TmplCache[K, V]) Set(key K, value V) {
26
+
c.mutex.Lock()
27
+
defer c.mutex.Unlock()
28
+
c.data[key] = value
29
+
}
30
+
31
+
func (c *TmplCache[K, V]) Size() int {
32
+
c.mutex.RLock()
33
+
defer c.mutex.RUnlock()
34
+
return len(c.data)
35
+
}
+3
appview/pages/funcmap.go
+3
appview/pages/funcmap.go
···
29
29
"split": func(s string) []string {
30
30
return strings.Split(s, "\n")
31
31
},
32
+
"contains": func(s string, target string) bool {
33
+
return strings.Contains(s, target)
34
+
},
32
35
"resolve": func(s string) string {
33
36
identity, err := p.resolver.ResolveIdent(context.Background(), s)
34
37
+12
appview/pages/markup/format.go
+12
appview/pages/markup/format.go
···
13
13
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
14
14
}
15
15
16
+
// ReadmeFilenames contains the list of common README filenames to search for,
17
+
// in order of preference. Only includes well-supported formats.
18
+
var ReadmeFilenames = []string{
19
+
"README.md", "readme.md",
20
+
"README",
21
+
"readme",
22
+
"README.markdown",
23
+
"readme.markdown",
24
+
"README.txt",
25
+
"readme.txt",
26
+
}
27
+
16
28
func GetFormat(filename string) Format {
17
29
for format, extensions := range FileTypes {
18
30
for _, extension := range extensions {
+10
-8
appview/pages/markup/markdown.go
+10
-8
appview/pages/markup/markdown.go
···
22
22
"github.com/yuin/goldmark/util"
23
23
htmlparse "golang.org/x/net/html"
24
24
25
+
"tangled.sh/tangled.sh/core/api/tangled"
25
26
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
26
27
)
27
28
···
231
232
232
233
actualPath := rctx.actualPath(dst)
233
234
235
+
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
236
+
237
+
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
238
+
url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath)
239
+
234
240
parsedURL := &url.URL{
235
-
Scheme: scheme,
236
-
Host: rctx.Knot,
237
-
Path: path.Join("/",
238
-
rctx.RepoInfo.OwnerDid,
239
-
rctx.RepoInfo.Name,
240
-
"raw",
241
-
url.PathEscape(rctx.RepoInfo.Ref),
242
-
actualPath),
241
+
Scheme: scheme,
242
+
Host: rctx.Knot,
243
+
Path: path.Join("/xrpc", tangled.RepoBlobNSID),
244
+
RawQuery: query,
243
245
}
244
246
newPath := parsedURL.String()
245
247
return newPath
+270
-190
appview/pages/pages.go
+270
-190
appview/pages/pages.go
···
9
9
"html/template"
10
10
"io"
11
11
"io/fs"
12
-
"log"
12
+
"log/slog"
13
13
"net/http"
14
14
"os"
15
15
"path/filepath"
···
42
42
var Files embed.FS
43
43
44
44
type Pages struct {
45
-
mu sync.RWMutex
46
-
t map[string]*template.Template
45
+
mu sync.RWMutex
46
+
cache *TmplCache[string, *template.Template]
47
47
48
48
avatar config.AvatarConfig
49
49
resolver *idresolver.Resolver
50
50
dev bool
51
-
embedFS embed.FS
51
+
embedFS fs.FS
52
52
templateDir string // Path to templates on disk for dev mode
53
53
rctx *markup.RenderContext
54
+
logger *slog.Logger
54
55
}
55
56
56
57
func NewPages(config *config.Config, res *idresolver.Resolver) *Pages {
···
64
65
65
66
p := &Pages{
66
67
mu: sync.RWMutex{},
67
-
t: make(map[string]*template.Template),
68
+
cache: NewTmplCache[string, *template.Template](),
68
69
dev: config.Core.Dev,
69
70
avatar: config.Avatar,
70
-
embedFS: Files,
71
71
rctx: rctx,
72
72
resolver: res,
73
73
templateDir: "appview/pages",
74
+
logger: slog.Default().With("component", "pages"),
74
75
}
75
76
76
-
// Initial load of all templates
77
-
p.loadAllTemplates()
77
+
if p.dev {
78
+
p.embedFS = os.DirFS(p.templateDir)
79
+
} else {
80
+
p.embedFS = Files
81
+
}
78
82
79
83
return p
80
84
}
81
85
82
-
func (p *Pages) loadAllTemplates() {
83
-
templates := make(map[string]*template.Template)
84
-
var fragmentPaths []string
86
+
func (p *Pages) pathToName(s string) string {
87
+
return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html")
88
+
}
85
89
86
-
// Use embedded FS for initial loading
87
-
// First, collect all fragment paths
90
+
// reverse of pathToName
91
+
func (p *Pages) nameToPath(s string) string {
92
+
return "templates/" + s + ".html"
93
+
}
94
+
95
+
func (p *Pages) fragmentPaths() ([]string, error) {
96
+
var fragmentPaths []string
88
97
err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
89
98
if err != nil {
90
99
return err
···
98
107
if !strings.Contains(path, "fragments/") {
99
108
return nil
100
109
}
101
-
name := strings.TrimPrefix(path, "templates/")
102
-
name = strings.TrimSuffix(name, ".html")
103
-
tmpl, err := template.New(name).
104
-
Funcs(p.funcMap()).
105
-
ParseFS(p.embedFS, path)
106
-
if err != nil {
107
-
log.Fatalf("setting up fragment: %v", err)
108
-
}
109
-
templates[name] = tmpl
110
110
fragmentPaths = append(fragmentPaths, path)
111
-
log.Printf("loaded fragment: %s", name)
112
111
return nil
113
112
})
114
113
if err != nil {
115
-
log.Fatalf("walking template dir for fragments: %v", err)
114
+
return nil, err
116
115
}
117
116
118
-
// Then walk through and setup the rest of the templates
119
-
err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
120
-
if err != nil {
121
-
return err
122
-
}
123
-
if d.IsDir() {
124
-
return nil
125
-
}
126
-
if !strings.HasSuffix(path, "html") {
127
-
return nil
128
-
}
129
-
// Skip fragments as they've already been loaded
130
-
if strings.Contains(path, "fragments/") {
131
-
return nil
132
-
}
133
-
// Skip layouts
134
-
if strings.Contains(path, "layouts/") {
135
-
return nil
136
-
}
137
-
name := strings.TrimPrefix(path, "templates/")
138
-
name = strings.TrimSuffix(name, ".html")
139
-
// Add the page template on top of the base
140
-
allPaths := []string{}
141
-
allPaths = append(allPaths, "templates/layouts/*.html")
142
-
allPaths = append(allPaths, fragmentPaths...)
143
-
allPaths = append(allPaths, path)
144
-
tmpl, err := template.New(name).
145
-
Funcs(p.funcMap()).
146
-
ParseFS(p.embedFS, allPaths...)
147
-
if err != nil {
148
-
return fmt.Errorf("setting up template: %w", err)
149
-
}
150
-
templates[name] = tmpl
151
-
log.Printf("loaded template: %s", name)
152
-
return nil
153
-
})
117
+
return fragmentPaths, nil
118
+
}
119
+
120
+
// parse without memoization
121
+
func (p *Pages) rawParse(stack ...string) (*template.Template, error) {
122
+
paths, err := p.fragmentPaths()
154
123
if err != nil {
155
-
log.Fatalf("walking template dir: %v", err)
124
+
return nil, err
156
125
}
157
-
158
-
log.Printf("total templates loaded: %d", len(templates))
159
-
p.mu.Lock()
160
-
defer p.mu.Unlock()
161
-
p.t = templates
162
-
}
163
-
164
-
// loadTemplateFromDisk loads a template from the filesystem in dev mode
165
-
func (p *Pages) loadTemplateFromDisk(name string) error {
166
-
if !p.dev {
167
-
return nil
126
+
for _, s := range stack {
127
+
paths = append(paths, p.nameToPath(s))
168
128
}
169
129
170
-
log.Printf("reloading template from disk: %s", name)
171
-
172
-
// Find all fragments first
173
-
var fragmentPaths []string
174
-
err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error {
175
-
if err != nil {
176
-
return err
177
-
}
178
-
if d.IsDir() {
179
-
return nil
180
-
}
181
-
if !strings.HasSuffix(path, ".html") {
182
-
return nil
183
-
}
184
-
if !strings.Contains(path, "fragments/") {
185
-
return nil
186
-
}
187
-
fragmentPaths = append(fragmentPaths, path)
188
-
return nil
189
-
})
130
+
funcs := p.funcMap()
131
+
top := stack[len(stack)-1]
132
+
parsed, err := template.New(top).
133
+
Funcs(funcs).
134
+
ParseFS(p.embedFS, paths...)
190
135
if err != nil {
191
-
return fmt.Errorf("walking disk template dir for fragments: %w", err)
136
+
return nil, err
192
137
}
193
138
194
-
// Find the template path on disk
195
-
templatePath := filepath.Join(p.templateDir, "templates", name+".html")
196
-
if _, err := os.Stat(templatePath); os.IsNotExist(err) {
197
-
return fmt.Errorf("template not found on disk: %s", name)
198
-
}
139
+
return parsed, nil
140
+
}
199
141
200
-
// Create a new template
201
-
tmpl := template.New(name).Funcs(p.funcMap())
142
+
func (p *Pages) parse(stack ...string) (*template.Template, error) {
143
+
key := strings.Join(stack, "|")
202
144
203
-
// Parse layouts
204
-
layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html")
205
-
layouts, err := filepath.Glob(layoutGlob)
145
+
// never cache in dev mode
146
+
if cached, exists := p.cache.Get(key); !p.dev && exists {
147
+
return cached, nil
148
+
}
149
+
150
+
result, err := p.rawParse(stack...)
206
151
if err != nil {
207
-
return fmt.Errorf("finding layout templates: %w", err)
152
+
return nil, err
208
153
}
209
154
210
-
// Create paths for parsing
211
-
allFiles := append(layouts, fragmentPaths...)
212
-
allFiles = append(allFiles, templatePath)
155
+
p.cache.Set(key, result)
156
+
return result, nil
157
+
}
213
158
214
-
// Parse all templates
215
-
tmpl, err = tmpl.ParseFiles(allFiles...)
216
-
if err != nil {
217
-
return fmt.Errorf("parsing template files: %w", err)
159
+
func (p *Pages) parseBase(top string) (*template.Template, error) {
160
+
stack := []string{
161
+
"layouts/base",
162
+
top,
218
163
}
164
+
return p.parse(stack...)
165
+
}
219
166
220
-
// Update the template in the map
221
-
p.mu.Lock()
222
-
defer p.mu.Unlock()
223
-
p.t[name] = tmpl
224
-
log.Printf("template reloaded from disk: %s", name)
225
-
return nil
167
+
func (p *Pages) parseRepoBase(top string) (*template.Template, error) {
168
+
stack := []string{
169
+
"layouts/base",
170
+
"layouts/repobase",
171
+
top,
172
+
}
173
+
return p.parse(stack...)
226
174
}
227
175
228
-
func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error {
229
-
// In dev mode, reload the template from disk before executing
230
-
if p.dev {
231
-
if err := p.loadTemplateFromDisk(templateName); err != nil {
232
-
log.Printf("warning: failed to reload template %s from disk: %v", templateName, err)
233
-
// Continue with the existing template
234
-
}
176
+
func (p *Pages) parseProfileBase(top string) (*template.Template, error) {
177
+
stack := []string{
178
+
"layouts/base",
179
+
"layouts/profilebase",
180
+
top,
235
181
}
182
+
return p.parse(stack...)
183
+
}
236
184
237
-
p.mu.RLock()
238
-
defer p.mu.RUnlock()
239
-
tmpl, exists := p.t[templateName]
240
-
if !exists {
241
-
return fmt.Errorf("template not found: %s", templateName)
185
+
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
186
+
tpl, err := p.parse(name)
187
+
if err != nil {
188
+
return err
242
189
}
243
190
244
-
if base == "" {
245
-
return tmpl.Execute(w, params)
246
-
} else {
247
-
return tmpl.ExecuteTemplate(w, base, params)
248
-
}
191
+
return tpl.Execute(w, params)
249
192
}
250
193
251
194
func (p *Pages) execute(name string, w io.Writer, params any) error {
252
-
return p.executeOrReload(name, w, "layouts/base", params)
253
-
}
195
+
tpl, err := p.parseBase(name)
196
+
if err != nil {
197
+
return err
198
+
}
254
199
255
-
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
256
-
return p.executeOrReload(name, w, "", params)
200
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
257
201
}
258
202
259
203
func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
260
-
return p.executeOrReload(name, w, "layouts/repobase", params)
204
+
tpl, err := p.parseRepoBase(name)
205
+
if err != nil {
206
+
return err
207
+
}
208
+
209
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
210
+
}
211
+
212
+
func (p *Pages) executeProfile(name string, w io.Writer, params any) error {
213
+
tpl, err := p.parseProfileBase(name)
214
+
if err != nil {
215
+
return err
216
+
}
217
+
218
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
261
219
}
262
220
263
221
func (p *Pages) Favicon(w io.Writer) error {
···
282
240
283
241
type TermsOfServiceParams struct {
284
242
LoggedInUser *oauth.User
243
+
Content template.HTML
285
244
}
286
245
287
246
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
247
+
filename := "terms.md"
248
+
filePath := filepath.Join("legal", filename)
249
+
markdownBytes, err := os.ReadFile(filePath)
250
+
if err != nil {
251
+
return fmt.Errorf("failed to read %s: %w", filename, err)
252
+
}
253
+
254
+
p.rctx.RendererType = markup.RendererTypeDefault
255
+
htmlString := p.rctx.RenderMarkdown(string(markdownBytes))
256
+
sanitized := p.rctx.SanitizeDefault(htmlString)
257
+
params.Content = template.HTML(sanitized)
258
+
288
259
return p.execute("legal/terms", w, params)
289
260
}
290
261
291
262
type PrivacyPolicyParams struct {
292
263
LoggedInUser *oauth.User
264
+
Content template.HTML
293
265
}
294
266
295
267
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
268
+
filename := "privacy.md"
269
+
filePath := filepath.Join("legal", filename)
270
+
markdownBytes, err := os.ReadFile(filePath)
271
+
if err != nil {
272
+
return fmt.Errorf("failed to read %s: %w", filename, err)
273
+
}
274
+
275
+
p.rctx.RendererType = markup.RendererTypeDefault
276
+
htmlString := p.rctx.RenderMarkdown(string(markdownBytes))
277
+
sanitized := p.rctx.SanitizeDefault(htmlString)
278
+
params.Content = template.HTML(sanitized)
279
+
296
280
return p.execute("legal/privacy", w, params)
297
281
}
298
282
···
338
322
return p.execute("user/settings/emails", w, params)
339
323
}
340
324
341
-
type KnotBannerParams struct {
325
+
type UpgradeBannerParams struct {
342
326
Registrations []db.Registration
327
+
Spindles []db.Spindle
343
328
}
344
329
345
-
func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error {
346
-
return p.executePlain("knots/fragments/banner", w, params)
330
+
func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
331
+
return p.executePlain("banner", w, params)
347
332
}
348
333
349
334
type KnotsParams struct {
···
422
407
return p.execute("repo/fork", w, params)
423
408
}
424
409
425
-
type ProfileHomePageParams struct {
410
+
type ProfileCard struct {
411
+
UserDid string
412
+
UserHandle string
413
+
FollowStatus db.FollowStatus
414
+
Punchcard *db.Punchcard
415
+
Profile *db.Profile
416
+
Stats ProfileStats
417
+
Active string
418
+
}
419
+
420
+
type ProfileStats struct {
421
+
RepoCount int64
422
+
StarredCount int64
423
+
StringCount int64
424
+
FollowersCount int64
425
+
FollowingCount int64
426
+
}
427
+
428
+
func (p *ProfileCard) GetTabs() [][]any {
429
+
tabs := [][]any{
430
+
{"overview", "overview", "square-chart-gantt", nil},
431
+
{"repos", "repos", "book-marked", p.Stats.RepoCount},
432
+
{"starred", "starred", "star", p.Stats.StarredCount},
433
+
{"strings", "strings", "line-squiggle", p.Stats.StringCount},
434
+
}
435
+
436
+
return tabs
437
+
}
438
+
439
+
type ProfileOverviewParams struct {
426
440
LoggedInUser *oauth.User
427
441
Repos []db.Repo
428
442
CollaboratingRepos []db.Repo
429
443
ProfileTimeline *db.ProfileTimeline
430
-
Card ProfileCard
431
-
Punchcard db.Punchcard
444
+
Card *ProfileCard
445
+
Active string
432
446
}
433
447
434
-
type ProfileCard struct {
435
-
UserDid string
436
-
UserHandle string
437
-
FollowStatus db.FollowStatus
438
-
FollowersCount int
439
-
FollowingCount int
448
+
func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error {
449
+
params.Active = "overview"
450
+
return p.executeProfile("user/overview", w, params)
451
+
}
440
452
441
-
Profile *db.Profile
453
+
type ProfileReposParams struct {
454
+
LoggedInUser *oauth.User
455
+
Repos []db.Repo
456
+
Card *ProfileCard
457
+
Active string
442
458
}
443
459
444
-
func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error {
445
-
return p.execute("user/profile", w, params)
460
+
func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error {
461
+
params.Active = "repos"
462
+
return p.executeProfile("user/repos", w, params)
446
463
}
447
464
448
-
type ReposPageParams struct {
465
+
type ProfileStarredParams struct {
449
466
LoggedInUser *oauth.User
450
467
Repos []db.Repo
451
-
Card ProfileCard
468
+
Card *ProfileCard
469
+
Active string
452
470
}
453
471
454
-
func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error {
455
-
return p.execute("user/repos", w, params)
472
+
func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error {
473
+
params.Active = "starred"
474
+
return p.executeProfile("user/starred", w, params)
475
+
}
476
+
477
+
type ProfileStringsParams struct {
478
+
LoggedInUser *oauth.User
479
+
Strings []db.String
480
+
Card *ProfileCard
481
+
Active string
482
+
}
483
+
484
+
func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error {
485
+
params.Active = "strings"
486
+
return p.executeProfile("user/strings", w, params)
456
487
}
457
488
458
489
type FollowCard struct {
459
490
UserDid string
460
491
FollowStatus db.FollowStatus
461
-
FollowersCount int
462
-
FollowingCount int
492
+
FollowersCount int64
493
+
FollowingCount int64
463
494
Profile *db.Profile
464
495
}
465
496
466
-
type FollowersPageParams struct {
497
+
type ProfileFollowersParams struct {
467
498
LoggedInUser *oauth.User
468
499
Followers []FollowCard
469
-
Card ProfileCard
500
+
Card *ProfileCard
501
+
Active string
470
502
}
471
503
472
-
func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error {
473
-
return p.execute("user/followers", w, params)
504
+
func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error {
505
+
params.Active = "overview"
506
+
return p.executeProfile("user/followers", w, params)
474
507
}
475
508
476
-
type FollowingPageParams struct {
509
+
type ProfileFollowingParams struct {
477
510
LoggedInUser *oauth.User
478
511
Following []FollowCard
479
-
Card ProfileCard
512
+
Card *ProfileCard
513
+
Active string
480
514
}
481
515
482
-
func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error {
483
-
return p.execute("user/following", w, params)
516
+
func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error {
517
+
params.Active = "overview"
518
+
return p.executeProfile("user/following", w, params)
484
519
}
485
520
486
521
type FollowFragmentParams struct {
···
553
588
VerifiedCommits commitverify.VerifiedCommits
554
589
Languages []types.RepoLanguageDetails
555
590
Pipelines map[string]db.Pipeline
591
+
NeedsKnotUpgrade bool
556
592
types.RepoIndexResponse
557
593
}
558
594
···
562
598
return p.executeRepo("repo/empty", w, params)
563
599
}
564
600
601
+
if params.NeedsKnotUpgrade {
602
+
return p.executeRepo("repo/needsUpgrade", w, params)
603
+
}
604
+
565
605
p.rctx.RepoInfo = params.RepoInfo
566
606
p.rctx.RepoInfo.Ref = params.Ref
567
607
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
···
649
689
650
690
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
651
691
params.Active = "overview"
652
-
return p.execute("repo/tree", w, params)
692
+
return p.executeRepo("repo/tree", w, params)
653
693
}
654
694
655
695
type RepoBranchesParams struct {
···
700
740
ShowRendered bool
701
741
RenderToggle bool
702
742
RenderedContents template.HTML
703
-
types.RepoBlobResponse
743
+
*tangled.RepoBlob_Output
744
+
// Computed fields for template compatibility
745
+
Contents string
746
+
Lines int
747
+
SizeHint uint64
748
+
IsBinary bool
704
749
}
705
750
706
751
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
···
835
880
RepoInfo repoinfo.RepoInfo
836
881
Active string
837
882
Issue *db.Issue
838
-
Comments []db.Comment
883
+
CommentList []db.CommentListItem
839
884
IssueOwnerHandle string
840
885
841
886
OrderedReactionKinds []db.ReactionKind
842
887
Reactions map[db.ReactionKind]int
843
888
UserReacted map[db.ReactionKind]bool
889
+
}
844
890
845
-
State string
891
+
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
892
+
params.Active = "issues"
893
+
return p.executeRepo("repo/issues/issue", w, params)
894
+
}
895
+
896
+
type EditIssueParams struct {
897
+
LoggedInUser *oauth.User
898
+
RepoInfo repoinfo.RepoInfo
899
+
Issue *db.Issue
900
+
Action string
901
+
}
902
+
903
+
func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error {
904
+
params.Action = "edit"
905
+
return p.executePlain("repo/issues/fragments/putIssue", w, params)
846
906
}
847
907
848
908
type ThreadReactionFragmentParams struct {
···
856
916
return p.executePlain("repo/fragments/reaction", w, params)
857
917
}
858
918
859
-
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
860
-
params.Active = "issues"
861
-
if params.Issue.Open {
862
-
params.State = "open"
863
-
} else {
864
-
params.State = "closed"
865
-
}
866
-
return p.execute("repo/issues/issue", w, params)
867
-
}
868
-
869
919
type RepoNewIssueParams struct {
870
920
LoggedInUser *oauth.User
871
921
RepoInfo repoinfo.RepoInfo
922
+
Issue *db.Issue // existing issue if any -- passed when editing
872
923
Active string
924
+
Action string
873
925
}
874
926
875
927
func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
876
928
params.Active = "issues"
929
+
params.Action = "create"
877
930
return p.executeRepo("repo/issues/new", w, params)
878
931
}
879
932
···
881
934
LoggedInUser *oauth.User
882
935
RepoInfo repoinfo.RepoInfo
883
936
Issue *db.Issue
884
-
Comment *db.Comment
937
+
Comment *db.IssueComment
885
938
}
886
939
887
940
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
888
941
return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
889
942
}
890
943
891
-
type SingleIssueCommentParams struct {
944
+
type ReplyIssueCommentPlaceholderParams struct {
892
945
LoggedInUser *oauth.User
893
946
RepoInfo repoinfo.RepoInfo
894
947
Issue *db.Issue
895
-
Comment *db.Comment
948
+
Comment *db.IssueComment
896
949
}
897
950
898
-
func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
899
-
return p.executePlain("repo/issues/fragments/issueComment", w, params)
951
+
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
952
+
return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params)
953
+
}
954
+
955
+
type ReplyIssueCommentParams struct {
956
+
LoggedInUser *oauth.User
957
+
RepoInfo repoinfo.RepoInfo
958
+
Issue *db.Issue
959
+
Comment *db.IssueComment
960
+
}
961
+
962
+
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
963
+
return p.executePlain("repo/issues/fragments/replyComment", w, params)
964
+
}
965
+
966
+
type IssueCommentBodyParams struct {
967
+
LoggedInUser *oauth.User
968
+
RepoInfo repoinfo.RepoInfo
969
+
Issue *db.Issue
970
+
Comment *db.IssueComment
971
+
}
972
+
973
+
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
974
+
return p.executePlain("repo/issues/fragments/issueCommentBody", w, params)
900
975
}
901
976
902
977
type RepoNewPullParams struct {
···
1262
1337
return p.execute("strings/string", w, params)
1263
1338
}
1264
1339
1340
+
func (p *Pages) Home(w io.Writer, params TimelineParams) error {
1341
+
return p.execute("timeline/home", w, params)
1342
+
}
1343
+
1265
1344
func (p *Pages) Static() http.Handler {
1266
1345
if p.dev {
1267
1346
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
···
1269
1348
1270
1349
sub, err := fs.Sub(Files, "static")
1271
1350
if err != nil {
1272
-
log.Fatalf("no static dir found? that's crazy: %v", err)
1351
+
p.logger.Error("no static dir found? that's crazy", "err", err)
1352
+
panic(err)
1273
1353
}
1274
1354
// Custom handler to apply Cache-Control headers for font files
1275
1355
return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
···
1292
1372
func CssContentHash() string {
1293
1373
cssFile, err := Files.Open("static/tw.css")
1294
1374
if err != nil {
1295
-
log.Printf("Error opening CSS file: %v", err)
1375
+
slog.Debug("Error opening CSS file", "err", err)
1296
1376
return ""
1297
1377
}
1298
1378
defer cssFile.Close()
1299
1379
1300
1380
hasher := sha256.New()
1301
1381
if _, err := io.Copy(hasher, cssFile); err != nil {
1302
-
log.Printf("Error hashing CSS file: %v", err)
1382
+
slog.Debug("Error hashing CSS file", "err", err)
1303
1383
return ""
1304
1384
}
1305
1385
+2
-7
appview/pages/repoinfo/repoinfo.go
+2
-7
appview/pages/repoinfo/repoinfo.go
···
78
78
func (r RepoInfo) TabMetadata() map[string]any {
79
79
meta := make(map[string]any)
80
80
81
-
if r.Stats.PullCount.Open > 0 {
82
-
meta["pulls"] = r.Stats.PullCount.Open
83
-
}
84
-
85
-
if r.Stats.IssueCount.Open > 0 {
86
-
meta["issues"] = r.Stats.IssueCount.Open
87
-
}
81
+
meta["pulls"] = r.Stats.PullCount.Open
82
+
meta["issues"] = r.Stats.IssueCount.Open
88
83
89
84
// more stuff?
90
85
+1
-1
appview/pages/templates/errors/404.html
+1
-1
appview/pages/templates/errors/404.html
···
17
17
The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL.
18
18
</p>
19
19
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
-
<a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
20
+
<a href="javascript:history.back()" class="btn no-underline hover:no-underline gap-2">
21
21
{{ i "arrow-left" "w-4 h-4" }}
22
22
go back
23
23
</a>
+4
-4
appview/pages/templates/errors/500.html
+4
-4
appview/pages/templates/errors/500.html
···
8
8
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
9
</div>
10
10
</div>
11
-
11
+
12
12
<div class="space-y-4">
13
13
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
14
14
500 — internal server error
···
24
24
<p class="mt-1">Our team has been automatically notified about this error.</p>
25
25
</div>
26
26
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
27
-
<button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white">
27
+
<button onclick="location.reload()" class="btn-create gap-2">
28
28
{{ i "refresh-cw" "w-4 h-4" }}
29
29
try again
30
30
</button>
31
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
31
+
<a href="/" class="btn no-underline hover:no-underline gap-2">
32
32
{{ i "home" "w-4 h-4" }}
33
33
back to home
34
34
</a>
···
36
36
</div>
37
37
</div>
38
38
</div>
39
-
{{ end }}
39
+
{{ end }}
+2
-2
appview/pages/templates/errors/503.html
+2
-2
appview/pages/templates/errors/503.html
···
17
17
We were unable to reach the knot hosting this repository. The service may be temporarily unavailable.
18
18
</p>
19
19
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
-
<button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white">
20
+
<button onclick="location.reload()" class="btn-create gap-2">
21
21
{{ i "refresh-cw" "w-4 h-4" }}
22
22
try again
23
23
</button>
24
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
24
+
<a href="/" class="btn gap-2 no-underline hover:no-underline">
25
25
{{ i "arrow-left" "w-4 h-4" }}
26
26
back to timeline
27
27
</a>
+1
-1
appview/pages/templates/errors/knot404.html
+1
-1
appview/pages/templates/errors/knot404.html
···
17
17
The repository you were looking for could not be found. The knot serving the repository may be unavailable.
18
18
</p>
19
19
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline">
20
+
<a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline">
21
21
{{ i "arrow-left" "w-4 h-4" }}
22
22
back to timeline
23
23
</a>
+8
appview/pages/templates/fragments/logotype.html
+8
appview/pages/templates/fragments/logotype.html
+2
-2
appview/pages/templates/knots/fragments/knotListing.html
+2
-2
appview/pages/templates/knots/fragments/knotListing.html
···
36
36
</span>
37
37
{{ template "knots/fragments/addMemberModal" . }}
38
38
{{ block "knotDeleteButton" . }} {{ end }}
39
-
{{ else if .IsReadOnly }}
39
+
{{ else if .IsNeedsUpgrade }}
40
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
41
+
{{ i "shield-alert" "w-4 h-4" }} needs upgrade
42
42
</span>
43
43
{{ block "knotRetryButton" . }} {{ end }}
44
44
{{ block "knotDeleteButton" . }} {{ end }}
+12
-10
appview/pages/templates/knots/index.html
+12
-10
appview/pages/templates/knots/index.html
···
1
1
{{ define "title" }}knots{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="px-6 py-4">
4
+
<div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom">
5
5
<h1 class="text-xl font-bold dark:text-white">Knots</h1>
6
+
<span class="flex items-center gap-1">
7
+
{{ i "book" "w-3 h-3" }}
8
+
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">docs</a>
9
+
</span>
6
10
</div>
7
11
8
12
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
···
15
19
{{ end }}
16
20
17
21
{{ define "about" }}
18
-
<section class="rounded flex flex-col gap-2">
19
-
<p class="dark:text-gray-300">
20
-
Knots are lightweight headless servers that enable users to host Git repositories with ease.
21
-
Knots are designed for either single or multi-tenant use which is perfect for self-hosting on a Raspberry Pi at home, or larger โcommunityโ servers.
22
-
When creating a repository, you can choose a knot to store it on.
23
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">
24
-
Checkout the documentation if you're interested in self-hosting.
25
-
</a>
22
+
<section class="rounded">
23
+
<p class="text-gray-500 dark:text-gray-400">
24
+
Knots are lightweight headless servers that enable users to host Git repositories with ease.
25
+
When creating a repository, you can choose a knot to store it on.
26
26
</p>
27
-
</section>
27
+
28
+
29
+
</section>
28
30
{{ end }}
29
31
30
32
{{ define "list" }}
+27
-12
appview/pages/templates/layouts/base.html
+27
-12
appview/pages/templates/layouts/base.html
···
3
3
<html lang="en" class="dark:bg-gray-900">
4
4
<head>
5
5
<meta charset="UTF-8" />
6
-
<meta
7
-
name="viewport"
8
-
content="width=device-width, initial-scale=1.0"
9
-
/>
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
7
+
<meta name="description" content="Social coding, but for real this time!"/>
10
8
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
11
-
<script src="/static/htmx.min.js"></script>
12
-
<script src="/static/htmx-ext-ws.min.js"></script>
9
+
10
+
<script defer src="/static/htmx.min.js"></script>
11
+
<script defer src="/static/htmx-ext-ws.min.js"></script>
12
+
13
+
<!-- preconnect to image cdn -->
14
+
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
+
<link rel="preconnect" href="https://camo.tangled.sh" />
16
+
17
+
<!-- preload main font -->
18
+
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
19
+
13
20
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
14
21
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
15
22
{{ block "extrameta" . }}{{ end }}
16
23
</head>
17
-
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
24
+
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-10 lg:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
18
25
{{ block "topbarLayout" . }}
19
-
<header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;">
20
-
{{ template "layouts/topbar" . }}
26
+
<header class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3" style="z-index: 20;">
27
+
28
+
{{ if .LoggedInUser }}
29
+
<div id="upgrade-banner"
30
+
hx-get="/upgradeBanner"
31
+
hx-trigger="load"
32
+
hx-swap="innerHTML">
33
+
</div>
34
+
{{ end }}
35
+
{{ template "layouts/fragments/topbar" . }}
21
36
</header>
22
37
{{ end }}
23
38
24
39
{{ block "mainLayout" . }}
25
-
<div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4">
40
+
<div class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 flex flex-col gap-4">
26
41
{{ block "contentLayout" . }}
27
42
<main class="col-span-1 md:col-span-8">
28
43
{{ block "content" . }}{{ end }}
···
38
53
{{ end }}
39
54
40
55
{{ block "footerLayout" . }}
41
-
<footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12">
42
-
{{ template "layouts/footer" . }}
56
+
<footer class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 mt-12">
57
+
{{ template "layouts/fragments/footer" . }}
43
58
</footer>
44
59
{{ end }}
45
60
</body>
+78
appview/pages/templates/layouts/fragments/topbar.html
+78
appview/pages/templates/layouts/fragments/topbar.html
···
1
+
{{ define "layouts/fragments/topbar" }}
2
+
<nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
+
<div class="flex justify-between p-0 items-center">
4
+
<div id="left-items">
5
+
<a href="/" hx-boost="true" class="text-lg">{{ template "fragments/logotype" }}</a>
6
+
</div>
7
+
8
+
<div id="right-items" class="flex items-center gap-2">
9
+
{{ with .LoggedInUser }}
10
+
{{ block "newButton" . }} {{ end }}
11
+
{{ block "dropDown" . }} {{ end }}
12
+
{{ else }}
13
+
<a href="/login">login</a>
14
+
<span class="text-gray-500 dark:text-gray-400">or</span>
15
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
16
+
join now {{ i "arrow-right" "size-4" }}
17
+
</a>
18
+
{{ end }}
19
+
</div>
20
+
</div>
21
+
</nav>
22
+
{{ end }}
23
+
24
+
{{ define "newButton" }}
25
+
<details class="relative inline-block text-left nav-dropdown">
26
+
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
27
+
{{ i "plus" "w-4 h-4" }} new
28
+
</summary>
29
+
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
30
+
<a href="/repo/new" class="flex items-center gap-2">
31
+
{{ i "book-plus" "w-4 h-4" }}
32
+
new repository
33
+
</a>
34
+
<a href="/strings/new" class="flex items-center gap-2">
35
+
{{ i "line-squiggle" "w-4 h-4" }}
36
+
new string
37
+
</a>
38
+
</div>
39
+
</details>
40
+
{{ end }}
41
+
42
+
{{ define "dropDown" }}
43
+
<details class="relative inline-block text-left nav-dropdown">
44
+
<summary
45
+
class="cursor-pointer list-none flex items-center"
46
+
>
47
+
{{ $user := didOrHandle .Did .Handle }}
48
+
{{ template "user/fragments/picHandle" $user }}
49
+
</summary>
50
+
<div
51
+
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
52
+
>
53
+
<a href="/{{ $user }}">profile</a>
54
+
<a href="/{{ $user }}?tab=repos">repositories</a>
55
+
<a href="/{{ $user }}?tab=strings">strings</a>
56
+
<a href="/knots">knots</a>
57
+
<a href="/spindles">spindles</a>
58
+
<a href="/settings">settings</a>
59
+
<a href="#"
60
+
hx-post="/logout"
61
+
hx-swap="none"
62
+
class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
63
+
logout
64
+
</a>
65
+
</div>
66
+
</details>
67
+
68
+
<script>
69
+
document.addEventListener('click', function(event) {
70
+
const dropdowns = document.querySelectorAll('.nav-dropdown');
71
+
dropdowns.forEach(function(dropdown) {
72
+
if (!dropdown.contains(event.target)) {
73
+
dropdown.removeAttribute('open');
74
+
}
75
+
});
76
+
});
77
+
</script>
78
+
{{ end }}
+104
appview/pages/templates/layouts/profilebase.html
+104
appview/pages/templates/layouts/profilebase.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
+
<meta property="og:type" content="profile" />
6
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" />
7
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
+
{{ end }}
9
+
10
+
{{ define "content" }}
11
+
{{ template "profileTabs" . }}
12
+
<section class="bg-white dark:bg-gray-800 p-6 rounded w-full dark:text-white drop-shadow-sm">
13
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
14
+
<div class="md:col-span-3 order-1 md:order-1">
15
+
<div class="flex flex-col gap-4">
16
+
{{ template "user/fragments/profileCard" .Card }}
17
+
{{ block "punchcard" .Card.Punchcard }} {{ end }}
18
+
</div>
19
+
</div>
20
+
{{ block "profileContent" . }} {{ end }}
21
+
</div>
22
+
</section>
23
+
{{ end }}
24
+
25
+
{{ define "profileTabs" }}
26
+
<nav class="w-full pl-4 overflow-x-auto overflow-y-hidden">
27
+
<div class="flex z-60">
28
+
{{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }}
29
+
{{ $tabs := .Card.GetTabs }}
30
+
{{ $tabmeta := dict "x" "y" }}
31
+
{{ range $item := $tabs }}
32
+
{{ $key := index $item 0 }}
33
+
{{ $value := index $item 1 }}
34
+
{{ $icon := index $item 2 }}
35
+
{{ $meta := index $item 3 }}
36
+
<a
37
+
href="?tab={{ $value }}"
38
+
class="relative -mr-px group no-underline hover:no-underline"
39
+
hx-boost="true">
40
+
<div
41
+
class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap
42
+
{{ if eq $.Active $key }}
43
+
{{ $activeTabStyles }}
44
+
{{ else }}
45
+
group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25
46
+
{{ end }}
47
+
">
48
+
<span class="flex items-center justify-center">
49
+
{{ i $icon "w-4 h-4 mr-2" }}
50
+
{{ $key }}
51
+
{{ if $meta }}
52
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
53
+
{{ end }}
54
+
</span>
55
+
</div>
56
+
</a>
57
+
{{ end }}
58
+
</div>
59
+
</nav>
60
+
{{ end }}
61
+
62
+
{{ define "punchcard" }}
63
+
{{ $now := now }}
64
+
<div>
65
+
<p class="px-2 pb-4 flex gap-2 text-sm font-bold dark:text-white">
66
+
PUNCHCARD
67
+
<span class="font-mono font-normal text-sm text-gray-500 dark:text-gray-400 ">
68
+
{{ .Total | int64 | commaFmt }} commits
69
+
</span>
70
+
</p>
71
+
<div class="grid grid-cols-28 md:grid-cols-14 gap-y-3 w-full h-full">
72
+
{{ range .Punches }}
73
+
{{ $count := .Count }}
74
+
{{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
75
+
{{ if lt $count 1 }}
76
+
{{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
77
+
{{ else if lt $count 2 }}
78
+
{{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }}
79
+
{{ else if lt $count 4 }}
80
+
{{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }}
81
+
{{ else if lt $count 8 }}
82
+
{{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }}
83
+
{{ else }}
84
+
{{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }}
85
+
{{ end }}
86
+
87
+
{{ if .Date.After $now }}
88
+
{{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }}
89
+
{{ end }}
90
+
<div class="w-full h-full flex justify-center items-center">
91
+
<div
92
+
class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full"
93
+
title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits">
94
+
</div>
95
+
</div>
96
+
{{ end }}
97
+
</div>
98
+
</div>
99
+
{{ end }}
100
+
101
+
{{ define "layouts/profilebase" }}
102
+
{{ template "layouts/base" . }}
103
+
{{ end }}
104
+
+4
-8
appview/pages/templates/layouts/repobase.html
+4
-8
appview/pages/templates/layouts/repobase.html
···
42
42
</section>
43
43
44
44
<section
45
-
class="w-full flex flex-col drop-shadow-sm"
45
+
class="w-full flex flex-col"
46
46
>
47
47
<nav class="w-full pl-4 overflow-auto">
48
48
<div class="flex z-60">
···
71
71
<span class="flex items-center justify-center">
72
72
{{ i $icon "w-4 h-4 mr-2" }}
73
73
{{ $key }}
74
-
{{ if not (isNil $meta) }}
75
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
74
+
{{ if $meta }}
75
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
76
76
{{ end }}
77
77
</span>
78
78
</div>
···
81
81
</div>
82
82
</nav>
83
83
<section
84
-
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white"
84
+
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"
85
85
>
86
86
{{ block "repoContent" . }}{{ end }}
87
87
</section>
88
88
{{ block "repoAfter" . }}{{ end }}
89
89
</section>
90
90
{{ end }}
91
-
92
-
{{ define "layouts/repobase" }}
93
-
{{ template "layouts/base" . }}
94
-
{{ end }}
-87
appview/pages/templates/layouts/topbar.html
-87
appview/pages/templates/layouts/topbar.html
···
1
-
{{ define "layouts/topbar" }}
2
-
<nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
-
<div class="flex justify-between p-0 items-center">
4
-
<div id="left-items">
5
-
<a href="/" hx-boost="true" class="flex gap-2 font-bold italic">
6
-
tangled<sub>alpha</sub>
7
-
</a>
8
-
</div>
9
-
10
-
<div id="right-items" class="flex items-center gap-2">
11
-
{{ with .LoggedInUser }}
12
-
{{ block "newButton" . }} {{ end }}
13
-
{{ block "dropDown" . }} {{ end }}
14
-
{{ else }}
15
-
<a href="/login">login</a>
16
-
<span class="text-gray-500 dark:text-gray-400">or</span>
17
-
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
18
-
join now {{ i "arrow-right" "size-4" }}
19
-
</a>
20
-
{{ end }}
21
-
</div>
22
-
</div>
23
-
</nav>
24
-
{{ if .LoggedInUser }}
25
-
<div id="upgrade-banner"
26
-
hx-get="/knots/upgradeBanner"
27
-
hx-trigger="load"
28
-
hx-swap="innerHTML">
29
-
</div>
30
-
{{ end }}
31
-
{{ end }}
32
-
33
-
{{ define "newButton" }}
34
-
<details class="relative inline-block text-left nav-dropdown">
35
-
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
36
-
{{ i "plus" "w-4 h-4" }} new
37
-
</summary>
38
-
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
39
-
<a href="/repo/new" class="flex items-center gap-2">
40
-
{{ i "book-plus" "w-4 h-4" }}
41
-
new repository
42
-
</a>
43
-
<a href="/strings/new" class="flex items-center gap-2">
44
-
{{ i "line-squiggle" "w-4 h-4" }}
45
-
new string
46
-
</a>
47
-
</div>
48
-
</details>
49
-
{{ end }}
50
-
51
-
{{ define "dropDown" }}
52
-
<details class="relative inline-block text-left nav-dropdown">
53
-
<summary
54
-
class="cursor-pointer list-none flex items-center"
55
-
>
56
-
{{ $user := didOrHandle .Did .Handle }}
57
-
{{ template "user/fragments/picHandle" $user }}
58
-
</summary>
59
-
<div
60
-
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
61
-
>
62
-
<a href="/{{ $user }}">profile</a>
63
-
<a href="/{{ $user }}?tab=repos">repositories</a>
64
-
<a href="/strings/{{ $user }}">strings</a>
65
-
<a href="/knots">knots</a>
66
-
<a href="/spindles">spindles</a>
67
-
<a href="/settings">settings</a>
68
-
<a href="#"
69
-
hx-post="/logout"
70
-
hx-swap="none"
71
-
class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
72
-
logout
73
-
</a>
74
-
</div>
75
-
</details>
76
-
77
-
<script>
78
-
document.addEventListener('click', function(event) {
79
-
const dropdowns = document.querySelectorAll('.nav-dropdown');
80
-
dropdowns.forEach(function(dropdown) {
81
-
if (!dropdown.contains(event.target)) {
82
-
dropdown.removeAttribute('open');
83
-
}
84
-
});
85
-
});
86
-
</script>
87
-
{{ end }}
+4
-126
appview/pages/templates/legal/privacy.html
+4
-126
appview/pages/templates/legal/privacy.html
···
1
-
{{ define "title" }} privacy policy {{ end }}
1
+
{{ define "title" }}privacy policy{{ end }}
2
+
2
3
{{ define "content" }}
3
4
<div class="max-w-4xl mx-auto px-4 py-8">
4
5
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
5
6
<div class="prose prose-gray dark:prose-invert max-w-none">
6
-
<h1>Privacy Policy</h1>
7
-
8
-
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
9
-
10
-
<p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p>
11
-
12
-
<h2>1. Information We Collect</h2>
13
-
14
-
<h3>Account Information</h3>
15
-
<p>When you create an account, we collect:</p>
16
-
<ul>
17
-
<li>Your chosen username</li>
18
-
<li>Email address</li>
19
-
<li>Profile information you choose to provide</li>
20
-
<li>Authentication data</li>
21
-
</ul>
22
-
23
-
<h3>Content and Activity</h3>
24
-
<p>We store:</p>
25
-
<ul>
26
-
<li>Code repositories and associated metadata</li>
27
-
<li>Issues, pull requests, and comments</li>
28
-
<li>Activity logs and usage patterns</li>
29
-
<li>Public keys for authentication</li>
30
-
</ul>
31
-
32
-
<h2>2. Data Location and Hosting</h2>
33
-
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6">
34
-
<h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3>
35
-
<p class="text-blue-700 dark:text-blue-300">
36
-
<strong>All Tangled service data is hosted within the European Union.</strong> Specifically:
37
-
</p>
38
-
<ul class="text-blue-700 dark:text-blue-300 mt-2">
39
-
<li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li>
40
-
<li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li>
41
-
<li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li>
42
-
</ul>
43
-
</div>
44
-
45
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6">
46
-
<h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3>
47
-
<p class="text-yellow-700 dark:text-yellow-300">
48
-
<strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure.
49
-
</p>
50
-
</div>
51
-
52
-
<h2>3. Third-Party Data Processors</h2>
53
-
<p>We only share your data with the following third-party processors:</p>
54
-
55
-
<h3>Resend (Email Services)</h3>
56
-
<ul>
57
-
<li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li>
58
-
<li><strong>Data Shared:</strong> Email address and necessary message content</li>
59
-
<li><strong>Location:</strong> EU-compliant email delivery service</li>
60
-
</ul>
61
-
62
-
<h3>Cloudflare (Image Caching)</h3>
63
-
<ul>
64
-
<li><strong>Purpose:</strong> Caching and optimizing image delivery</li>
65
-
<li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li>
66
-
<li><strong>Location:</strong> Global CDN with EU data protection compliance</li>
67
-
</ul>
68
-
69
-
<h2>4. How We Use Your Information</h2>
70
-
<p>We use your information to:</p>
71
-
<ul>
72
-
<li>Provide and maintain the Service</li>
73
-
<li>Process your transactions and requests</li>
74
-
<li>Send you technical notices and support messages</li>
75
-
<li>Improve and develop new features</li>
76
-
<li>Ensure security and prevent fraud</li>
77
-
<li>Comply with legal obligations</li>
78
-
</ul>
79
-
80
-
<h2>5. Data Sharing and Disclosure</h2>
81
-
<p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p>
82
-
<ul>
83
-
<li>With the third-party processors listed above</li>
84
-
<li>When required by law or legal process</li>
85
-
<li>To protect our rights, property, or safety, or that of our users</li>
86
-
<li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li>
87
-
</ul>
88
-
89
-
<h2>6. Data Security</h2>
90
-
<p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p>
91
-
92
-
<h2>7. Data Retention</h2>
93
-
<p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p>
94
-
95
-
<h2>8. Your Rights</h2>
96
-
<p>Under applicable data protection laws, you have the right to:</p>
97
-
<ul>
98
-
<li>Access your personal information</li>
99
-
<li>Correct inaccurate information</li>
100
-
<li>Request deletion of your information</li>
101
-
<li>Object to processing of your information</li>
102
-
<li>Data portability</li>
103
-
<li>Withdraw consent (where applicable)</li>
104
-
</ul>
105
-
106
-
<h2>9. Cookies and Tracking</h2>
107
-
<p>We use cookies and similar technologies to:</p>
108
-
<ul>
109
-
<li>Maintain your login session</li>
110
-
<li>Remember your preferences</li>
111
-
<li>Analyze usage patterns to improve the Service</li>
112
-
</ul>
113
-
<p>You can control cookie settings through your browser preferences.</p>
114
-
115
-
<h2>10. Children's Privacy</h2>
116
-
<p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p>
117
-
118
-
<h2>11. International Data Transfers</h2>
119
-
<p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p>
120
-
121
-
<h2>12. Changes to This Privacy Policy</h2>
122
-
<p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p>
123
-
124
-
<h2>13. Contact Information</h2>
125
-
<p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p>
126
-
127
-
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
128
-
<p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p>
129
-
</div>
7
+
{{ .Content }}
130
8
</div>
131
9
</div>
132
10
</div>
133
-
{{ end }}
11
+
{{ end }}
+2
-62
appview/pages/templates/legal/terms.html
+2
-62
appview/pages/templates/legal/terms.html
···
4
4
<div class="max-w-4xl mx-auto px-4 py-8">
5
5
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
6
<div class="prose prose-gray dark:prose-invert max-w-none">
7
-
<h1>Terms of Service</h1>
8
-
9
-
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
10
-
11
-
<p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p>
12
-
13
-
<h2>1. Acceptance of Terms</h2>
14
-
<p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p>
15
-
16
-
<h2>2. Account Registration</h2>
17
-
<p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p>
18
-
19
-
<h2>3. Account Termination</h2>
20
-
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6">
21
-
<h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3>
22
-
<p class="text-red-700 dark:text-red-300">
23
-
<strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users.
24
-
</p>
25
-
<p class="text-red-700 dark:text-red-300 mt-2">
26
-
Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion.
27
-
</p>
28
-
</div>
29
-
30
-
<h2>4. Acceptable Use</h2>
31
-
<p>You agree not to use the Service to:</p>
32
-
<ul>
33
-
<li>Violate any applicable laws or regulations</li>
34
-
<li>Infringe upon the rights of others</li>
35
-
<li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li>
36
-
<li>Engage in spam, phishing, or other deceptive practices</li>
37
-
<li>Attempt to gain unauthorized access to the Service or other users' accounts</li>
38
-
<li>Interfere with or disrupt the Service or servers connected to the Service</li>
39
-
</ul>
40
-
41
-
<h2>5. Content and Intellectual Property</h2>
42
-
<p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p>
43
-
44
-
<h2>6. Privacy</h2>
45
-
<p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p>
46
-
47
-
<h2>7. Disclaimers</h2>
48
-
<p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p>
49
-
50
-
<h2>8. Limitation of Liability</h2>
51
-
<p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p>
52
-
53
-
<h2>9. Indemnification</h2>
54
-
<p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p>
55
-
56
-
<h2>10. Governing Law</h2>
57
-
<p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p>
58
-
59
-
<h2>11. Changes to Terms</h2>
60
-
<p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p>
61
-
62
-
<h2>12. Contact Information</h2>
63
-
<p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p>
64
-
65
-
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
66
-
<p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p>
67
-
</div>
7
+
{{ .Content }}
68
8
</div>
69
9
</div>
70
10
</div>
71
-
{{ end }}
11
+
{{ end }}
+2
-2
appview/pages/templates/repo/commit.html
+2
-2
appview/pages/templates/repo/commit.html
···
81
81
82
82
{{ define "topbarLayout" }}
83
83
<header class="px-1 col-span-full" style="z-index: 20;">
84
-
{{ template "layouts/topbar" . }}
84
+
{{ template "layouts/fragments/topbar" . }}
85
85
</header>
86
86
{{ end }}
87
87
···
106
106
107
107
{{ define "footerLayout" }}
108
108
<footer class="px-1 col-span-full mt-12">
109
-
{{ template "layouts/footer" . }}
109
+
{{ template "layouts/fragments/footer" . }}
110
110
</footer>
111
111
{{ end }}
112
112
+2
-2
appview/pages/templates/repo/compare/compare.html
+2
-2
appview/pages/templates/repo/compare/compare.html
···
12
12
13
13
{{ define "topbarLayout" }}
14
14
<header class="px-1 col-span-full" style="z-index: 20;">
15
-
{{ template "layouts/topbar" . }}
15
+
{{ template "layouts/fragments/topbar" . }}
16
16
</header>
17
17
{{ end }}
18
18
···
37
37
38
38
{{ define "footerLayout" }}
39
39
<footer class="px-1 col-span-full mt-12">
40
-
{{ template "layouts/footer" . }}
40
+
{{ template "layouts/fragments/footer" . }}
41
41
</footer>
42
42
{{ end }}
43
43
+1
-1
appview/pages/templates/repo/fork.html
+1
-1
appview/pages/templates/repo/fork.html
+6
appview/pages/templates/repo/fragments/diff.html
+6
appview/pages/templates/repo/fragments/diff.html
···
11
11
{{ $last := sub (len $diff) 1 }}
12
12
13
13
<div class="flex flex-col gap-4">
14
+
{{ if eq (len $diff) 0 }}
15
+
<div class="text-center text-gray-500 dark:text-gray-400 py-8">
16
+
<p>No differences found between the selected revisions.</p>
17
+
</div>
18
+
{{ else }}
14
19
{{ range $idx, $hunk := $diff }}
15
20
{{ with $hunk }}
16
21
<details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
···
49
54
</div>
50
55
</details>
51
56
{{ end }}
57
+
{{ end }}
52
58
{{ end }}
53
59
</div>
54
60
{{ end }}
+4
appview/pages/templates/repo/fragments/duration.html
+4
appview/pages/templates/repo/fragments/duration.html
+6
appview/pages/templates/repo/fragments/languageBall.html
+6
appview/pages/templates/repo/fragments/languageBall.html
···
1
+
{{ define "repo/fragments/languageBall" }}
2
+
<div
3
+
class="size-2 rounded-full"
4
+
style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"
5
+
></div>
6
+
{{ end }}
+4
appview/pages/templates/repo/fragments/shortTime.html
+4
appview/pages/templates/repo/fragments/shortTime.html
+4
appview/pages/templates/repo/fragments/shortTimeAgo.html
+4
appview/pages/templates/repo/fragments/shortTimeAgo.html
-16
appview/pages/templates/repo/fragments/time.html
-16
appview/pages/templates/repo/fragments/time.html
···
1
-
{{ define "repo/fragments/timeWrapper" }}
2
-
<time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time>
3
-
{{ end }}
4
-
5
1
{{ define "repo/fragments/time" }}
6
2
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }}
7
3
{{ end }}
8
-
9
-
{{ define "repo/fragments/shortTime" }}
10
-
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }}
11
-
{{ end }}
12
-
13
-
{{ define "repo/fragments/shortTimeAgo" }}
14
-
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }}
15
-
{{ end }}
16
-
17
-
{{ define "repo/fragments/duration" }}
18
-
<time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time>
19
-
{{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
+5
appview/pages/templates/repo/fragments/timeWrapper.html
+25
-8
appview/pages/templates/repo/index.html
+25
-8
appview/pages/templates/repo/index.html
···
35
35
{{ end }}
36
36
37
37
{{ define "repoLanguages" }}
38
-
<div class="flex gap-[1px] -m-6 mb-6 overflow-hidden rounded-t">
38
+
<details class="group -m-6 mb-4">
39
+
<summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t">
40
+
{{ range $value := .Languages }}
41
+
<div
42
+
title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%'
43
+
style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%"
44
+
></div>
45
+
{{ end }}
46
+
</summary>
47
+
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap">
39
48
{{ range $value := .Languages }}
40
-
<div
41
-
title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%'
42
-
class="h-[4px] rounded-full"
43
-
style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%"
44
-
></div>
49
+
<div
50
+
class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center"
51
+
>
52
+
{{ template "repo/fragments/languageBall" $value.Name }}
53
+
<div>{{ or $value.Name "Other" }}
54
+
<span class="text-gray-500 dark:text-gray-400">
55
+
{{ if lt $value.Percentage 0.05 }}
56
+
0.1%
57
+
{{ else }}
58
+
{{ printf "%.1f" $value.Percentage }}%
59
+
{{ end }}
60
+
</span></div>
61
+
</div>
45
62
{{ end }}
46
-
</div>
63
+
</div>
64
+
</details>
47
65
{{ end }}
48
-
49
66
50
67
{{ define "branchSelector" }}
51
68
<div class="flex gap-2 items-center justify-between w-full">
+58
appview/pages/templates/repo/issues/fragments/commentList.html
+58
appview/pages/templates/repo/issues/fragments/commentList.html
···
1
+
{{ define "repo/issues/fragments/commentList" }}
2
+
<div class="flex flex-col gap-8">
3
+
{{ range $item := .CommentList }}
4
+
{{ template "commentListing" (list $ .) }}
5
+
{{ end }}
6
+
<div>
7
+
{{ end }}
8
+
9
+
{{ define "commentListing" }}
10
+
{{ $root := index . 0 }}
11
+
{{ $comment := index . 1 }}
12
+
{{ $params :=
13
+
(dict
14
+
"RepoInfo" $root.RepoInfo
15
+
"LoggedInUser" $root.LoggedInUser
16
+
"Issue" $root.Issue
17
+
"Comment" $comment.Self) }}
18
+
19
+
<div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm">
20
+
{{ template "topLevelComment" $params }}
21
+
22
+
<div class="relative ml-4 border-l border-gray-300 dark:border-gray-700">
23
+
{{ range $index, $reply := $comment.Replies }}
24
+
<div class="relative ">
25
+
<!-- Horizontal connector -->
26
+
<div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div>
27
+
28
+
<div class="pl-2">
29
+
{{
30
+
template "replyComment"
31
+
(dict
32
+
"RepoInfo" $root.RepoInfo
33
+
"LoggedInUser" $root.LoggedInUser
34
+
"Issue" $root.Issue
35
+
"Comment" $reply)
36
+
}}
37
+
</div>
38
+
</div>
39
+
{{ end }}
40
+
</div>
41
+
42
+
{{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }}
43
+
</div>
44
+
{{ end }}
45
+
46
+
{{ define "topLevelComment" }}
47
+
<div class="rounded px-6 py-4 bg-white dark:bg-gray-800">
48
+
{{ template "repo/issues/fragments/issueCommentHeader" . }}
49
+
{{ template "repo/issues/fragments/issueCommentBody" . }}
50
+
</div>
51
+
{{ end }}
52
+
53
+
{{ define "replyComment" }}
54
+
<div class="p-4 w-full mx-auto overflow-hidden">
55
+
{{ template "repo/issues/fragments/issueCommentHeader" . }}
56
+
{{ template "repo/issues/fragments/issueCommentBody" . }}
57
+
</div>
58
+
{{ end }}
+37
-45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
+37
-45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
···
1
1
{{ define "repo/issues/fragments/editIssueComment" }}
2
-
{{ with .Comment }}
3
-
<div id="comment-container-{{.CommentId}}">
4
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
-
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
6
-
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
2
+
<div id="comment-body-{{.Comment.Id}}" class="pt-2">
3
+
<textarea
4
+
id="edit-textarea-{{ .Comment.Id }}"
5
+
name="body"
6
+
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
7
+
rows="5"
8
+
autofocus>{{ .Comment.Body }}</textarea>
7
9
8
-
<!-- show user "hats" -->
9
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
10
-
{{ if $isIssueAuthor }}
11
-
<span class="before:content-['ยท']"></span>
12
-
author
13
-
{{ end }}
14
-
15
-
<span class="before:content-['ยท']"></span>
16
-
<a
17
-
href="#{{ .CommentId }}"
18
-
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
19
-
id="{{ .CommentId }}">
20
-
{{ template "repo/fragments/time" .Created }}
21
-
</a>
22
-
23
-
<button
24
-
class="btn px-2 py-1 flex items-center gap-2 text-sm group"
25
-
hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
26
-
hx-include="#edit-textarea-{{ .CommentId }}"
27
-
hx-target="#comment-container-{{ .CommentId }}"
28
-
hx-swap="outerHTML">
29
-
{{ i "check" "w-4 h-4" }}
30
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
31
-
</button>
32
-
<button
33
-
class="btn px-2 py-1 flex items-center gap-2 text-sm"
34
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
35
-
hx-target="#comment-container-{{ .CommentId }}"
36
-
hx-swap="outerHTML">
37
-
{{ i "x" "w-4 h-4" }}
38
-
</button>
39
-
<span id="comment-{{.CommentId}}-status"></span>
40
-
</div>
10
+
{{ template "editActions" $ }}
11
+
</div>
12
+
{{ end }}
41
13
42
-
<div>
43
-
<textarea
44
-
id="edit-textarea-{{ .CommentId }}"
45
-
name="body"
46
-
class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea>
47
-
</div>
14
+
{{ define "editActions" }}
15
+
<div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2">
16
+
{{ template "cancel" . }}
17
+
{{ template "save" . }}
48
18
</div>
49
-
{{ end }}
19
+
{{ end }}
20
+
21
+
{{ define "save" }}
22
+
<button
23
+
class="btn-create py-0 flex gap-1 items-center group text-sm"
24
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
25
+
hx-include="#edit-textarea-{{ .Comment.Id }}"
26
+
hx-target="#comment-body-{{ .Comment.Id }}"
27
+
hx-swap="outerHTML">
28
+
{{ i "check" "size-4" }}
29
+
save
30
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
31
+
</button>
50
32
{{ end }}
51
33
34
+
{{ define "cancel" }}
35
+
<button
36
+
class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group"
37
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
38
+
hx-target="#comment-body-{{ .Comment.Id }}"
39
+
hx-swap="outerHTML">
40
+
{{ i "x" "size-4" }}
41
+
cancel
42
+
</button>
43
+
{{ end }}
-58
appview/pages/templates/repo/issues/fragments/issueComment.html
-58
appview/pages/templates/repo/issues/fragments/issueComment.html
···
1
-
{{ define "repo/issues/fragments/issueComment" }}
2
-
{{ with .Comment }}
3
-
<div id="comment-container-{{.CommentId}}">
4
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
-
{{ template "user/fragments/picHandleLink" .OwnerDid }}
6
-
7
-
<!-- show user "hats" -->
8
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
9
-
{{ if $isIssueAuthor }}
10
-
<span class="before:content-['ยท']"></span>
11
-
author
12
-
{{ end }}
13
-
14
-
<span class="before:content-['ยท']"></span>
15
-
<a
16
-
href="#{{ .CommentId }}"
17
-
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
18
-
id="{{ .CommentId }}">
19
-
{{ if .Deleted }}
20
-
deleted {{ template "repo/fragments/time" .Deleted }}
21
-
{{ else if .Edited }}
22
-
edited {{ template "repo/fragments/time" .Edited }}
23
-
{{ else }}
24
-
{{ template "repo/fragments/time" .Created }}
25
-
{{ end }}
26
-
</a>
27
-
28
-
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
29
-
{{ if and $isCommentOwner (not .Deleted) }}
30
-
<button
31
-
class="btn px-2 py-1 text-sm"
32
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
33
-
hx-swap="outerHTML"
34
-
hx-target="#comment-container-{{.CommentId}}"
35
-
>
36
-
{{ i "pencil" "w-4 h-4" }}
37
-
</button>
38
-
<button
39
-
class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group"
40
-
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
41
-
hx-confirm="Are you sure you want to delete your comment?"
42
-
hx-swap="outerHTML"
43
-
hx-target="#comment-container-{{.CommentId}}"
44
-
>
45
-
{{ i "trash-2" "w-4 h-4" }}
46
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
47
-
</button>
48
-
{{ end }}
49
-
50
-
</div>
51
-
{{ if not .Deleted }}
52
-
<div class="prose dark:prose-invert">
53
-
{{ .Body | markdown }}
54
-
</div>
55
-
{{ end }}
56
-
</div>
57
-
{{ end }}
58
-
{{ end }}
+34
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
+34
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
···
1
+
{{ define "repo/issues/fragments/issueCommentActions" }}
2
+
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
3
+
{{ if and $isCommentOwner (not .Comment.Deleted) }}
4
+
<div class="flex flex-wrap items-center gap-4 text-gray-500 dark:text-gray-400 text-sm pt-2">
5
+
{{ template "edit" . }}
6
+
{{ template "delete" . }}
7
+
</div>
8
+
{{ end }}
9
+
{{ end }}
10
+
11
+
{{ define "edit" }}
12
+
<a
13
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
14
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
15
+
hx-swap="outerHTML"
16
+
hx-target="#comment-body-{{.Comment.Id}}">
17
+
{{ i "pencil" "size-3" }}
18
+
edit
19
+
</a>
20
+
{{ end }}
21
+
22
+
{{ define "delete" }}
23
+
<a
24
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
25
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
26
+
hx-confirm="Are you sure you want to delete your comment?"
27
+
hx-swap="outerHTML"
28
+
hx-target="#comment-body-{{.Comment.Id}}"
29
+
>
30
+
{{ i "trash-2" "size-3" }}
31
+
delete
32
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
33
+
</a>
34
+
{{ end }}
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
···
1
+
{{ define "repo/issues/fragments/issueCommentBody" }}
2
+
<div id="comment-body-{{.Comment.Id}}">
3
+
{{ if not .Comment.Deleted }}
4
+
<div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div>
5
+
{{ else }}
6
+
<div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div>
7
+
{{ end }}
8
+
</div>
9
+
{{ end }}
+56
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
+56
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
1
+
{{ define "repo/issues/fragments/issueCommentHeader" }}
2
+
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 ">
3
+
{{ template "user/fragments/picHandleLink" .Comment.Did }}
4
+
{{ template "hats" $ }}
5
+
{{ template "timestamp" . }}
6
+
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
7
+
{{ if and $isCommentOwner (not .Comment.Deleted) }}
8
+
{{ template "editIssueComment" . }}
9
+
{{ template "deleteIssueComment" . }}
10
+
{{ end }}
11
+
</div>
12
+
{{ end }}
13
+
14
+
{{ define "hats" }}
15
+
{{ $isIssueAuthor := eq .Comment.Did .Issue.Did }}
16
+
{{ if $isIssueAuthor }}
17
+
(author)
18
+
{{ end }}
19
+
{{ end }}
20
+
21
+
{{ define "timestamp" }}
22
+
<a href="#{{ .Comment.Id }}"
23
+
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
24
+
id="{{ .Comment.Id }}">
25
+
{{ if .Comment.Deleted }}
26
+
{{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }}
27
+
{{ else if .Comment.Edited }}
28
+
edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }}
29
+
{{ else }}
30
+
{{ template "repo/fragments/shortTimeAgo" .Comment.Created }}
31
+
{{ end }}
32
+
</a>
33
+
{{ end }}
34
+
35
+
{{ define "editIssueComment" }}
36
+
<a
37
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
38
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
39
+
hx-swap="outerHTML"
40
+
hx-target="#comment-body-{{.Comment.Id}}">
41
+
{{ i "pencil" "size-3" }}
42
+
</a>
43
+
{{ end }}
44
+
45
+
{{ define "deleteIssueComment" }}
46
+
<a
47
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
48
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
49
+
hx-confirm="Are you sure you want to delete your comment?"
50
+
hx-swap="outerHTML"
51
+
hx-target="#comment-body-{{.Comment.Id}}"
52
+
>
53
+
{{ i "trash-2" "size-3" }}
54
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
55
+
</a>
56
+
{{ end }}
+145
appview/pages/templates/repo/issues/fragments/newComment.html
+145
appview/pages/templates/repo/issues/fragments/newComment.html
···
1
+
{{ define "repo/issues/fragments/newComment" }}
2
+
{{ if .LoggedInUser }}
3
+
<form
4
+
id="comment-form"
5
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
6
+
hx-on::after-request="if(event.detail.successful) this.reset()"
7
+
>
8
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full">
9
+
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
10
+
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
11
+
</div>
12
+
<textarea
13
+
id="comment-textarea"
14
+
name="body"
15
+
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
16
+
placeholder="Add to the discussion. Markdown is supported."
17
+
onkeyup="updateCommentForm()"
18
+
rows="5"
19
+
></textarea>
20
+
<div id="issue-comment"></div>
21
+
<div id="issue-action" class="error"></div>
22
+
</div>
23
+
24
+
<div class="flex gap-2 mt-2">
25
+
<button
26
+
id="comment-button"
27
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
28
+
type="submit"
29
+
hx-disabled-elt="#comment-button"
30
+
class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline group"
31
+
disabled
32
+
>
33
+
{{ i "message-square-plus" "w-4 h-4" }}
34
+
comment
35
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
36
+
</button>
37
+
38
+
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
39
+
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
40
+
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
41
+
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) .Issue.Open }}
42
+
<button
43
+
id="close-button"
44
+
type="button"
45
+
class="btn flex items-center gap-2"
46
+
hx-indicator="#close-spinner"
47
+
hx-trigger="click"
48
+
>
49
+
{{ i "ban" "w-4 h-4" }}
50
+
close
51
+
<span id="close-spinner" class="group">
52
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
53
+
</span>
54
+
</button>
55
+
<div
56
+
id="close-with-comment"
57
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
58
+
hx-trigger="click from:#close-button"
59
+
hx-disabled-elt="#close-with-comment"
60
+
hx-target="#issue-comment"
61
+
hx-indicator="#close-spinner"
62
+
hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}"
63
+
hx-swap="none"
64
+
>
65
+
</div>
66
+
<div
67
+
id="close-issue"
68
+
hx-disabled-elt="#close-issue"
69
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close"
70
+
hx-trigger="click from:#close-button"
71
+
hx-target="#issue-action"
72
+
hx-indicator="#close-spinner"
73
+
hx-swap="none"
74
+
>
75
+
</div>
76
+
<script>
77
+
document.addEventListener('htmx:configRequest', function(evt) {
78
+
if (evt.target.id === 'close-with-comment') {
79
+
const commentText = document.getElementById('comment-textarea').value.trim();
80
+
if (commentText === '') {
81
+
evt.detail.parameters = {};
82
+
evt.preventDefault();
83
+
}
84
+
}
85
+
});
86
+
</script>
87
+
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }}
88
+
<button
89
+
type="button"
90
+
class="btn flex items-center gap-2"
91
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen"
92
+
hx-indicator="#reopen-spinner"
93
+
hx-swap="none"
94
+
>
95
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
96
+
reopen
97
+
<span id="reopen-spinner" class="group">
98
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
99
+
</span>
100
+
</button>
101
+
{{ end }}
102
+
103
+
<script>
104
+
function updateCommentForm() {
105
+
const textarea = document.getElementById('comment-textarea');
106
+
const commentButton = document.getElementById('comment-button');
107
+
const closeButton = document.getElementById('close-button');
108
+
109
+
if (textarea.value.trim() !== '') {
110
+
commentButton.removeAttribute('disabled');
111
+
} else {
112
+
commentButton.setAttribute('disabled', '');
113
+
}
114
+
115
+
if (closeButton) {
116
+
if (textarea.value.trim() !== '') {
117
+
closeButton.innerHTML = `
118
+
{{ i "ban" "w-4 h-4" }}
119
+
<span>close with comment</span>
120
+
<span id="close-spinner" class="group">
121
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
122
+
</span>`;
123
+
} else {
124
+
closeButton.innerHTML = `
125
+
{{ i "ban" "w-4 h-4" }}
126
+
<span>close</span>
127
+
<span id="close-spinner" class="group">
128
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
129
+
</span>`;
130
+
}
131
+
}
132
+
}
133
+
134
+
document.addEventListener('DOMContentLoaded', function() {
135
+
updateCommentForm();
136
+
});
137
+
</script>
138
+
</div>
139
+
</form>
140
+
{{ else }}
141
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
142
+
<a href="/login" class="underline">login</a> to join the discussion
143
+
</div>
144
+
{{ end }}
145
+
{{ end }}
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
···
1
+
{{ define "repo/issues/fragments/putIssue" }}
2
+
<!-- this form is used for new and edit, .Issue is passed when editing -->
3
+
<form
4
+
{{ if eq .Action "edit" }}
5
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
6
+
{{ else }}
7
+
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
8
+
{{ end }}
9
+
hx-swap="none"
10
+
hx-indicator="#spinner">
11
+
<div class="flex flex-col gap-2">
12
+
<div>
13
+
<label for="title">title</label>
14
+
<input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" />
15
+
</div>
16
+
<div>
17
+
<label for="body">body</label>
18
+
<textarea
19
+
name="body"
20
+
id="body"
21
+
rows="6"
22
+
class="w-full resize-y"
23
+
placeholder="Describe your issue. Markdown is supported."
24
+
>{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea>
25
+
</div>
26
+
<div class="flex justify-between">
27
+
<div id="issues" class="error"></div>
28
+
<div class="flex gap-2 items-center">
29
+
<a
30
+
class="btn flex items-center gap-2 no-underline hover:no-underline"
31
+
type="button"
32
+
{{ if .Issue }}
33
+
href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}"
34
+
{{ else }}
35
+
href="/{{ .RepoInfo.FullName }}/issues"
36
+
{{ end }}
37
+
>
38
+
{{ i "x" "w-4 h-4" }}
39
+
cancel
40
+
</a>
41
+
<button type="submit" class="btn-create flex items-center gap-2">
42
+
{{ if eq .Action "edit" }}
43
+
{{ i "pencil" "w-4 h-4" }}
44
+
{{ .Action }} issue
45
+
{{ else }}
46
+
{{ i "circle-plus" "w-4 h-4" }}
47
+
{{ .Action }} issue
48
+
{{ end }}
49
+
<span id="spinner" class="group">
50
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
51
+
</span>
52
+
</button>
53
+
</div>
54
+
</div>
55
+
</div>
56
+
</form>
57
+
{{ end }}
+61
appview/pages/templates/repo/issues/fragments/replyComment.html
+61
appview/pages/templates/repo/issues/fragments/replyComment.html
···
1
+
{{ define "repo/issues/fragments/replyComment" }}
2
+
<form
3
+
class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2"
4
+
id="reply-form-{{ .Comment.Id }}"
5
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
6
+
hx-on::after-request="if(event.detail.successful) this.reset()"
7
+
hx-disabled-elt="#reply-{{ .Comment.Id }}"
8
+
>
9
+
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
10
+
<textarea
11
+
id="reply-{{.Comment.Id}}-textarea"
12
+
name="body"
13
+
class="w-full p-2"
14
+
placeholder="Leave a reply..."
15
+
autofocus
16
+
rows="3"
17
+
hx-trigger="keydown[ctrlKey&&key=='Enter']"
18
+
hx-target="#reply-form-{{ .Comment.Id }}"
19
+
hx-get="#"
20
+
hx-on:htmx:before-request="event.preventDefault(); document.getElementById('reply-form-{{ .Comment.Id }}').requestSubmit()"></textarea>
21
+
22
+
<input
23
+
type="text"
24
+
id="reply-to"
25
+
name="reply-to"
26
+
required
27
+
value="{{ .Comment.AtUri }}"
28
+
class="hidden"
29
+
/>
30
+
{{ template "replyActions" . }}
31
+
</form>
32
+
{{ end }}
33
+
34
+
{{ define "replyActions" }}
35
+
<div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm">
36
+
{{ template "cancel" . }}
37
+
{{ template "reply" . }}
38
+
</div>
39
+
{{ end }}
40
+
41
+
{{ define "cancel" }}
42
+
<button
43
+
class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group"
44
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/replyPlaceholder"
45
+
hx-target="#reply-form-{{ .Comment.Id }}"
46
+
hx-swap="outerHTML">
47
+
{{ i "x" "size-4" }}
48
+
cancel
49
+
</button>
50
+
{{ end }}
51
+
52
+
{{ define "reply" }}
53
+
<button
54
+
id="reply-{{ .Comment.Id }}"
55
+
type="submit"
56
+
class="btn-create flex items-center gap-2 no-underline hover:no-underline">
57
+
{{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }}
58
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
59
+
reply
60
+
</button>
61
+
{{ end }}
+20
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
+20
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
···
1
+
{{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }}
2
+
<div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700">
3
+
{{ if .LoggedInUser }}
4
+
<img
5
+
src="{{ tinyAvatar .LoggedInUser.Did }}"
6
+
alt=""
7
+
class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700"
8
+
/>
9
+
{{ end }}
10
+
<input
11
+
class="w-full py-2 border-none focus:outline-none"
12
+
placeholder="Leave a reply..."
13
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply"
14
+
hx-trigger="focus"
15
+
hx-target="closest div"
16
+
hx-swap="outerHTML"
17
+
>
18
+
</input>
19
+
</div>
20
+
{{ end }}
+95
-202
appview/pages/templates/repo/issues/issue.html
+95
-202
appview/pages/templates/repo/issues/issue.html
···
9
9
{{ end }}
10
10
11
11
{{ define "repoContent" }}
12
-
<header class="pb-4">
13
-
<h1 class="text-2xl">
14
-
{{ .Issue.Title | description }}
15
-
<span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span>
16
-
</h1>
17
-
</header>
12
+
<section id="issue-{{ .Issue.IssueId }}">
13
+
{{ template "issueHeader" .Issue }}
14
+
{{ template "issueInfo" . }}
15
+
{{ if .Issue.Body }}
16
+
<article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article>
17
+
{{ end }}
18
+
{{ template "issueReactions" . }}
19
+
</section>
20
+
{{ end }}
18
21
19
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
20
-
{{ $icon := "ban" }}
21
-
{{ if eq .State "open" }}
22
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
23
-
{{ $icon = "circle-dot" }}
24
-
{{ end }}
22
+
{{ define "issueHeader" }}
23
+
<header class="pb-2">
24
+
<h1 class="text-2xl">
25
+
{{ .Title | description }}
26
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
27
+
</h1>
28
+
</header>
29
+
{{ end }}
25
30
26
-
<section class="mt-2">
27
-
<div class="inline-flex items-center gap-2">
28
-
<div id="state"
29
-
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}">
30
-
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
31
-
<span class="text-white">{{ .State }}</span>
32
-
</div>
33
-
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
34
-
opened by
35
-
{{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
36
-
{{ template "user/fragments/picHandleLink" $owner }}
37
-
<span class="select-none before:content-['\00B7']"></span>
38
-
{{ template "repo/fragments/time" .Issue.Created }}
39
-
</span>
40
-
</div>
31
+
{{ define "issueInfo" }}
32
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
33
+
{{ $icon := "ban" }}
34
+
{{ if eq .Issue.State "open" }}
35
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
36
+
{{ $icon = "circle-dot" }}
37
+
{{ end }}
38
+
<div class="inline-flex items-center gap-2">
39
+
<div id="state"
40
+
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}">
41
+
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
42
+
<span class="text-white">{{ .Issue.State }}</span>
43
+
</div>
44
+
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
45
+
opened by
46
+
{{ template "user/fragments/picHandleLink" .Issue.Did }}
47
+
<span class="select-none before:content-['\00B7']"></span>
48
+
{{ if .Issue.Edited }}
49
+
edited {{ template "repo/fragments/time" .Issue.Edited }}
50
+
{{ else }}
51
+
{{ template "repo/fragments/time" .Issue.Created }}
52
+
{{ end }}
53
+
</span>
41
54
42
-
{{ if .Issue.Body }}
43
-
<article id="body" class="mt-8 prose dark:prose-invert">
44
-
{{ .Issue.Body | markdown }}
45
-
</article>
46
-
{{ end }}
55
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
56
+
{{ template "issueActions" . }}
57
+
{{ end }}
58
+
</div>
59
+
<div id="issue-actions-error" class="error"></div>
60
+
{{ end }}
47
61
48
-
<div class="flex items-center gap-2 mt-2">
49
-
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
50
-
{{ range $kind := .OrderedReactionKinds }}
51
-
{{
52
-
template "repo/fragments/reaction"
53
-
(dict
54
-
"Kind" $kind
55
-
"Count" (index $.Reactions $kind)
56
-
"IsReacted" (index $.UserReacted $kind)
57
-
"ThreadAt" $.Issue.AtUri)
58
-
}}
59
-
{{ end }}
60
-
</div>
61
-
</section>
62
+
{{ define "issueActions" }}
63
+
{{ template "editIssue" . }}
64
+
{{ template "deleteIssue" . }}
62
65
{{ end }}
63
66
64
-
{{ define "repoAfter" }}
65
-
<section id="comments" class="my-2 mt-2 space-y-2 relative">
66
-
{{ range $index, $comment := .Comments }}
67
-
<div
68
-
id="comment-{{ .CommentId }}"
69
-
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
70
-
{{ if gt $index 0 }}
71
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
72
-
{{ end }}
73
-
{{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}}
74
-
</div>
75
-
{{ end }}
76
-
</section>
67
+
{{ define "editIssue" }}
68
+
<a
69
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
70
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
71
+
hx-swap="innerHTML"
72
+
hx-target="#issue-{{.Issue.IssueId}}">
73
+
{{ i "pencil" "size-3" }}
74
+
</a>
75
+
{{ end }}
77
76
78
-
{{ block "newComment" . }} {{ end }}
77
+
{{ define "deleteIssue" }}
78
+
<a
79
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
80
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/"
81
+
hx-confirm="Are you sure you want to delete your issue?"
82
+
hx-swap="none">
83
+
{{ i "trash-2" "size-3" }}
84
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
85
+
</a>
86
+
{{ end }}
79
87
88
+
{{ define "issueReactions" }}
89
+
<div class="flex items-center gap-2 mt-2">
90
+
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
91
+
{{ range $kind := .OrderedReactionKinds }}
92
+
{{
93
+
template "repo/fragments/reaction"
94
+
(dict
95
+
"Kind" $kind
96
+
"Count" (index $.Reactions $kind)
97
+
"IsReacted" (index $.UserReacted $kind)
98
+
"ThreadAt" $.Issue.AtUri)
99
+
}}
100
+
{{ end }}
101
+
</div>
80
102
{{ end }}
81
103
82
-
{{ define "newComment" }}
83
-
{{ if .LoggedInUser }}
84
-
<form
85
-
id="comment-form"
86
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
87
-
hx-on::after-request="if(event.detail.successful) this.reset()"
88
-
>
89
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5">
90
-
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
91
-
{{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }}
92
-
</div>
93
-
<textarea
94
-
id="comment-textarea"
95
-
name="body"
96
-
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
97
-
placeholder="Add to the discussion. Markdown is supported."
98
-
onkeyup="updateCommentForm()"
99
-
></textarea>
100
-
<div id="issue-comment"></div>
101
-
<div id="issue-action" class="error"></div>
102
-
</div>
103
-
104
-
<div class="flex gap-2 mt-2">
105
-
<button
106
-
id="comment-button"
107
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
108
-
type="submit"
109
-
hx-disabled-elt="#comment-button"
110
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"
111
-
disabled
112
-
>
113
-
{{ i "message-square-plus" "w-4 h-4" }}
114
-
comment
115
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
116
-
</button>
117
-
118
-
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }}
119
-
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
120
-
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
121
-
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }}
122
-
<button
123
-
id="close-button"
124
-
type="button"
125
-
class="btn flex items-center gap-2"
126
-
hx-indicator="#close-spinner"
127
-
hx-trigger="click"
128
-
>
129
-
{{ i "ban" "w-4 h-4" }}
130
-
close
131
-
<span id="close-spinner" class="group">
132
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
133
-
</span>
134
-
</button>
135
-
<div
136
-
id="close-with-comment"
137
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
138
-
hx-trigger="click from:#close-button"
139
-
hx-disabled-elt="#close-with-comment"
140
-
hx-target="#issue-comment"
141
-
hx-indicator="#close-spinner"
142
-
hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}"
143
-
hx-swap="none"
144
-
>
145
-
</div>
146
-
<div
147
-
id="close-issue"
148
-
hx-disabled-elt="#close-issue"
149
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close"
150
-
hx-trigger="click from:#close-button"
151
-
hx-target="#issue-action"
152
-
hx-indicator="#close-spinner"
153
-
hx-swap="none"
154
-
>
155
-
</div>
156
-
<script>
157
-
document.addEventListener('htmx:configRequest', function(evt) {
158
-
if (evt.target.id === 'close-with-comment') {
159
-
const commentText = document.getElementById('comment-textarea').value.trim();
160
-
if (commentText === '') {
161
-
evt.detail.parameters = {};
162
-
evt.preventDefault();
163
-
}
164
-
}
165
-
});
166
-
</script>
167
-
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }}
168
-
<button
169
-
type="button"
170
-
class="btn flex items-center gap-2"
171
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen"
172
-
hx-indicator="#reopen-spinner"
173
-
hx-swap="none"
174
-
>
175
-
{{ i "refresh-ccw-dot" "w-4 h-4" }}
176
-
reopen
177
-
<span id="reopen-spinner" class="group">
178
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
179
-
</span>
180
-
</button>
181
-
{{ end }}
182
-
183
-
<script>
184
-
function updateCommentForm() {
185
-
const textarea = document.getElementById('comment-textarea');
186
-
const commentButton = document.getElementById('comment-button');
187
-
const closeButton = document.getElementById('close-button');
188
-
189
-
if (textarea.value.trim() !== '') {
190
-
commentButton.removeAttribute('disabled');
191
-
} else {
192
-
commentButton.setAttribute('disabled', '');
193
-
}
104
+
{{ define "repoAfter" }}
105
+
<div class="flex flex-col gap-4 mt-4">
106
+
{{
107
+
template "repo/issues/fragments/commentList"
108
+
(dict
109
+
"RepoInfo" $.RepoInfo
110
+
"LoggedInUser" $.LoggedInUser
111
+
"Issue" $.Issue
112
+
"CommentList" $.Issue.CommentList)
113
+
}}
194
114
195
-
if (closeButton) {
196
-
if (textarea.value.trim() !== '') {
197
-
closeButton.innerHTML = `
198
-
{{ i "ban" "w-4 h-4" }}
199
-
<span>close with comment</span>
200
-
<span id="close-spinner" class="group">
201
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
202
-
</span>`;
203
-
} else {
204
-
closeButton.innerHTML = `
205
-
{{ i "ban" "w-4 h-4" }}
206
-
<span>close</span>
207
-
<span id="close-spinner" class="group">
208
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
209
-
</span>`;
210
-
}
211
-
}
212
-
}
115
+
{{ template "repo/issues/fragments/newComment" . }}
116
+
<div>
117
+
{{ end }}
213
118
214
-
document.addEventListener('DOMContentLoaded', function() {
215
-
updateCommentForm();
216
-
});
217
-
</script>
218
-
</div>
219
-
</form>
220
-
{{ else }}
221
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
222
-
<a href="/login" class="underline">login</a> to join the discussion
223
-
</div>
224
-
{{ end }}
225
-
{{ end }}
+42
-44
appview/pages/templates/repo/issues/issues.html
+42
-44
appview/pages/templates/repo/issues/issues.html
···
37
37
{{ end }}
38
38
39
39
{{ define "repoAfter" }}
40
-
<div class="flex flex-col gap-2 mt-2">
41
-
{{ range .Issues }}
42
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
43
-
<div class="pb-2">
44
-
<a
45
-
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
46
-
class="no-underline hover:underline"
47
-
>
48
-
{{ .Title | description }}
49
-
<span class="text-gray-500">#{{ .IssueId }}</span>
50
-
</a>
51
-
</div>
52
-
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
53
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
54
-
{{ $icon := "ban" }}
55
-
{{ $state := "closed" }}
56
-
{{ if .Open }}
57
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
58
-
{{ $icon = "circle-dot" }}
59
-
{{ $state = "open" }}
60
-
{{ end }}
40
+
<div class="flex flex-col gap-2 mt-2">
41
+
{{ range .Issues }}
42
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
43
+
<div class="pb-2">
44
+
<a
45
+
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
46
+
class="no-underline hover:underline"
47
+
>
48
+
{{ .Title | description }}
49
+
<span class="text-gray-500">#{{ .IssueId }}</span>
50
+
</a>
51
+
</div>
52
+
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
53
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
54
+
{{ $icon := "ban" }}
55
+
{{ $state := "closed" }}
56
+
{{ if .Open }}
57
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
58
+
{{ $icon = "circle-dot" }}
59
+
{{ $state = "open" }}
60
+
{{ end }}
61
61
62
-
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
63
-
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
64
-
<span class="text-white dark:text-white">{{ $state }}</span>
65
-
</span>
62
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
63
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
64
+
<span class="text-white dark:text-white">{{ $state }}</span>
65
+
</span>
66
66
67
-
<span class="ml-1">
68
-
{{ template "user/fragments/picHandleLink" .OwnerDid }}
69
-
</span>
67
+
<span class="ml-1">
68
+
{{ template "user/fragments/picHandleLink" .Did }}
69
+
</span>
70
70
71
-
<span class="before:content-['ยท']">
72
-
{{ template "repo/fragments/time" .Created }}
73
-
</span>
71
+
<span class="before:content-['ยท']">
72
+
{{ template "repo/fragments/time" .Created }}
73
+
</span>
74
74
75
-
<span class="before:content-['ยท']">
76
-
{{ $s := "s" }}
77
-
{{ if eq .Metadata.CommentCount 1 }}
78
-
{{ $s = "" }}
79
-
{{ end }}
80
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a>
81
-
</span>
82
-
</p>
75
+
<span class="before:content-['ยท']">
76
+
{{ $s := "s" }}
77
+
{{ if eq (len .Comments) 1 }}
78
+
{{ $s = "" }}
79
+
{{ end }}
80
+
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
81
+
</span>
82
+
</p>
83
+
</div>
84
+
{{ end }}
83
85
</div>
84
-
{{ end }}
85
-
</div>
86
-
87
-
{{ block "pagination" . }} {{ end }}
88
-
86
+
{{ block "pagination" . }} {{ end }}
89
87
{{ end }}
90
88
91
89
{{ define "pagination" }}
+1
-33
appview/pages/templates/repo/issues/new.html
+1
-33
appview/pages/templates/repo/issues/new.html
···
1
1
{{ define "title" }}new issue · {{ .RepoInfo.FullName }}{{ end }}
2
2
3
3
{{ define "repoContent" }}
4
-
<form
5
-
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
6
-
class="mt-6 space-y-6"
7
-
hx-swap="none"
8
-
hx-indicator="#spinner"
9
-
>
10
-
<div class="flex flex-col gap-4">
11
-
<div>
12
-
<label for="title">title</label>
13
-
<input type="text" name="title" id="title" class="w-full" />
14
-
</div>
15
-
<div>
16
-
<label for="body">body</label>
17
-
<textarea
18
-
name="body"
19
-
id="body"
20
-
rows="6"
21
-
class="w-full resize-y"
22
-
placeholder="Describe your issue. Markdown is supported."
23
-
></textarea>
24
-
</div>
25
-
<div>
26
-
<button type="submit" class="btn-create flex items-center gap-2">
27
-
{{ i "circle-plus" "w-4 h-4" }}
28
-
create issue
29
-
<span id="create-pull-spinner" class="group">
30
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
31
-
</span>
32
-
</button>
33
-
</div>
34
-
</div>
35
-
<div id="issues" class="error"></div>
36
-
</form>
4
+
{{ template "repo/issues/fragments/putIssue" . }}
37
5
{{ end }}
+60
appview/pages/templates/repo/needsUpgrade.html
+60
appview/pages/templates/repo/needsUpgrade.html
···
1
+
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
+
{{ define "extrameta" }}
3
+
{{ template "repo/fragments/meta" . }}
4
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }}
5
+
{{ end }}
6
+
{{ define "repoContent" }}
7
+
<main>
8
+
<div class="relative w-full h-96 flex items-center justify-center">
9
+
<div class="w-full h-full grid grid-cols-1 md:grid-cols-2 gap-4 md:divide-x divide-gray-300 dark:divide-gray-600 text-gray-300 dark:text-gray-600">
10
+
<!-- mimic the repo view here, placeholders are LLM generated -->
11
+
<div id="file-list" class="flex flex-col gap-2 col-span-1 w-full h-full p-4 items-start justify-start text-left">
12
+
{{ $files :=
13
+
(list
14
+
"src"
15
+
"docs"
16
+
"config"
17
+
"lib"
18
+
"index.html"
19
+
"log.html"
20
+
"needsUpgrade.html"
21
+
"new.html"
22
+
"tags.html"
23
+
"tree.html")
24
+
}}
25
+
{{ range $files }}
26
+
<span>
27
+
{{ if (contains . ".") }}
28
+
{{ i "file" "size-4 inline-flex" }}
29
+
{{ else }}
30
+
{{ i "folder" "size-4 inline-flex fill-current" }}
31
+
{{ end }}
32
+
33
+
{{ . }}
34
+
</span>
35
+
{{ end }}
36
+
</div>
37
+
<div id="commit-list" class="hidden md:flex md:flex-col gap-4 col-span-1 w-full h-full p-4 items-start justify-start text-left">
38
+
{{ $commits :=
39
+
(list
40
+
"Fix authentication bug in login flow"
41
+
"Add new dashboard widgets for metrics"
42
+
"Implement real-time notifications system")
43
+
}}
44
+
{{ range $commits }}
45
+
<div class="flex flex-col">
46
+
<span>{{ . }}</span>
47
+
<span class="text-xs">{{ . }}</span>
48
+
</div>
49
+
{{ end }}
50
+
</div>
51
+
</div>
52
+
<div class="absolute inset-0 flex items-center justify-center py-12 text-red-500 dark:text-red-400 backdrop-blur">
53
+
<div class="text-center">
54
+
{{ i "triangle-alert" "size-5 inline-flex items-center align-middle" }}
55
+
The knot hosting this repository needs an upgrade. This repository is currently unavailable.
56
+
</div>
57
+
</div>
58
+
</div>
59
+
</main>
60
+
{{ end }}
+1
-1
appview/pages/templates/repo/new.html
+1
-1
appview/pages/templates/repo/new.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullStack.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullStack.html
···
52
52
</div>
53
53
{{ end }}
54
54
<div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2">
55
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
55
+
{{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }}
56
56
</div>
57
57
</div>
58
58
</a>
+1
-1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
+1
-1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
+2
-2
appview/pages/templates/repo/pulls/interdiff.html
+2
-2
appview/pages/templates/repo/pulls/interdiff.html
···
30
30
31
31
{{ define "topbarLayout" }}
32
32
<header class="px-1 col-span-full" style="z-index: 20;">
33
-
{{ template "layouts/topbar" . }}
33
+
{{ template "layouts/fragments/topbar" . }}
34
34
</header>
35
35
{{ end }}
36
36
···
55
55
56
56
{{ define "footerLayout" }}
57
57
<footer class="px-1 col-span-full mt-12">
58
-
{{ template "layouts/footer" . }}
58
+
{{ template "layouts/fragments/footer" . }}
59
59
</footer>
60
60
{{ end }}
61
61
+2
-2
appview/pages/templates/repo/pulls/patch.html
+2
-2
appview/pages/templates/repo/pulls/patch.html
···
36
36
37
37
{{ define "topbarLayout" }}
38
38
<header class="px-1 col-span-full" style="z-index: 20;">
39
-
{{ template "layouts/topbar" . }}
39
+
{{ template "layouts/fragments/topbar" . }}
40
40
</header>
41
41
{{ end }}
42
42
···
61
61
62
62
{{ define "footerLayout" }}
63
63
<footer class="px-1 col-span-full mt-12">
64
-
{{ template "layouts/footer" . }}
64
+
{{ template "layouts/fragments/footer" . }}
65
65
</footer>
66
66
{{ end }}
67
67
+1
-1
appview/pages/templates/repo/pulls/pulls.html
+1
-1
appview/pages/templates/repo/pulls/pulls.html
···
144
144
<a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
145
145
<div class="flex gap-2 items-center px-6">
146
146
<div class="flex-grow min-w-0 w-full py-2">
147
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
147
+
{{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }}
148
148
</div>
149
149
</div>
150
150
</a>
+6
-1
appview/pages/templates/spindles/fragments/spindleListing.html
+6
-1
appview/pages/templates/spindles/fragments/spindleListing.html
···
30
30
{{ define "spindleRightSide" }}
31
31
<div id="right-side" class="flex gap-2">
32
32
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
33
-
{{ if .Verified }}
33
+
34
+
{{ if .NeedsUpgrade }}
35
+
<span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> {{ i "shield-alert" "w-4 h-4" }} needs upgrade </span>
36
+
{{ block "spindleRetryButton" . }} {{ end }}
37
+
{{ else if .Verified }}
34
38
<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>
35
39
{{ template "spindles/fragments/addMemberModal" . }}
36
40
{{ else }}
37
41
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span>
38
42
{{ block "spindleRetryButton" . }} {{ end }}
39
43
{{ end }}
44
+
40
45
{{ block "spindleDeleteButton" . }} {{ end }}
41
46
</div>
42
47
{{ end }}
+10
-9
appview/pages/templates/spindles/index.html
+10
-9
appview/pages/templates/spindles/index.html
···
1
1
{{ define "title" }}spindles{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="px-6 py-4">
5
-
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
4
+
<div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom">
5
+
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
6
+
<span class="flex items-center gap-1">
7
+
{{ i "book" "w-3 h-3" }}
8
+
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a>
9
+
</span>
6
10
</div>
7
11
8
12
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
···
15
19
{{ end }}
16
20
17
21
{{ define "about" }}
18
-
<section class="rounded flex flex-col gap-2">
19
-
<p class="dark:text-gray-300">
20
-
Spindles are small CI runners.
21
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
22
-
Checkout the documentation if you're interested in self-hosting.
23
-
</a>
22
+
<section class="rounded flex items-center gap-2">
23
+
<p class="text-gray-500 dark:text-gray-400">
24
+
Spindles are small CI runners.
24
25
</p>
25
-
</section>
26
+
</section>
26
27
{{ end }}
27
28
28
29
{{ define "list" }}
-4
appview/pages/templates/strings/put.html
-4
appview/pages/templates/strings/put.html
-4
appview/pages/templates/strings/string.html
-4
appview/pages/templates/strings/string.html
···
8
8
<meta property="og:description" content="{{ .String.Description }}" />
9
9
{{ end }}
10
10
11
-
{{ define "topbar" }}
12
-
{{ template "layouts/topbar" $ }}
13
-
{{ end }}
14
-
15
11
{{ define "content" }}
16
12
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
17
13
<section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
-4
appview/pages/templates/strings/timeline.html
-4
appview/pages/templates/strings/timeline.html
+34
appview/pages/templates/timeline/fragments/hero.html
+34
appview/pages/templates/timeline/fragments/hero.html
···
1
+
{{ define "timeline/fragments/hero" }}
2
+
<div class="mx-auto max-w-[100rem] flex flex-col text-black dark:text-white px-6 py-4 gap-6 items-center md:flex-row">
3
+
<div class="flex flex-col gap-6">
4
+
<h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1>
5
+
6
+
<p class="text-lg">
7
+
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
8
+
</p>
9
+
<p class="text-lg">
10
+
we envision a place where developers have complete ownership of their
11
+
code, open source communities can freely self-govern and most
12
+
importantly, coding can be social and fun again.
13
+
</p>
14
+
15
+
<div class="flex gap-6 items-center">
16
+
<a href="/signup" class="no-underline hover:no-underline ">
17
+
<button class="btn-create flex gap-2 px-4 items-center">
18
+
join now {{ i "arrow-right" "size-4" }}
19
+
</button>
20
+
</a>
21
+
</div>
22
+
</div>
23
+
24
+
<figure class="w-full hidden md:block md:w-auto">
25
+
<a href="https://tangled.sh/@tangled.sh/core" class="block">
26
+
<img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" />
27
+
</a>
28
+
<figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center">
29
+
Monorepo for Tangled, built in the open with the community.
30
+
</figcaption>
31
+
</figure>
32
+
</div>
33
+
{{ end }}
34
+
+116
appview/pages/templates/timeline/fragments/timeline.html
+116
appview/pages/templates/timeline/fragments/timeline.html
···
1
+
{{ define "timeline/fragments/timeline" }}
2
+
<div class="py-4">
3
+
<div class="px-6 pb-4">
4
+
<p class="text-xl font-bold dark:text-white">Timeline</p>
5
+
</div>
6
+
7
+
<div class="flex flex-col gap-4">
8
+
{{ range $i, $e := .Timeline }}
9
+
<div class="relative">
10
+
{{ if ne $i 0 }}
11
+
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
12
+
{{ end }}
13
+
{{ with $e }}
14
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
15
+
{{ if .Repo }}
16
+
{{ template "timeline/fragments/repoEvent" (list $ .Repo .Source) }}
17
+
{{ else if .Star }}
18
+
{{ template "timeline/fragments/starEvent" (list $ .Star) }}
19
+
{{ else if .Follow }}
20
+
{{ template "timeline/fragments/followEvent" (list $ .Follow .Profile .FollowStats) }}
21
+
{{ end }}
22
+
</div>
23
+
{{ end }}
24
+
</div>
25
+
{{ end }}
26
+
</div>
27
+
</div>
28
+
{{ end }}
29
+
30
+
{{ define "timeline/fragments/repoEvent" }}
31
+
{{ $root := index . 0 }}
32
+
{{ $repo := index . 1 }}
33
+
{{ $source := index . 2 }}
34
+
{{ $userHandle := resolve $repo.Did }}
35
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
36
+
{{ template "user/fragments/picHandleLink" $repo.Did }}
37
+
{{ with $source }}
38
+
{{ $sourceDid := resolve .Did }}
39
+
forked
40
+
<a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline">
41
+
{{ $sourceDid }}/{{ .Name }}
42
+
</a>
43
+
to
44
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a>
45
+
{{ else }}
46
+
created
47
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">
48
+
{{ $repo.Name }}
49
+
</a>
50
+
{{ end }}
51
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
52
+
</div>
53
+
{{ with $repo }}
54
+
{{ template "user/fragments/repoCard" (list $root . true) }}
55
+
{{ end }}
56
+
{{ end }}
57
+
58
+
{{ define "timeline/fragments/starEvent" }}
59
+
{{ $root := index . 0 }}
60
+
{{ $star := index . 1 }}
61
+
{{ with $star }}
62
+
{{ $starrerHandle := resolve .StarredByDid }}
63
+
{{ $repoOwnerHandle := resolve .Repo.Did }}
64
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
65
+
{{ template "user/fragments/picHandleLink" $starrerHandle }}
66
+
starred
67
+
<a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">
68
+
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
69
+
</a>
70
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
71
+
</div>
72
+
{{ with .Repo }}
73
+
{{ template "user/fragments/repoCard" (list $root . true) }}
74
+
{{ end }}
75
+
{{ end }}
76
+
{{ end }}
77
+
78
+
{{ define "timeline/fragments/followEvent" }}
79
+
{{ $root := index . 0 }}
80
+
{{ $follow := index . 1 }}
81
+
{{ $profile := index . 2 }}
82
+
{{ $stat := index . 3 }}
83
+
84
+
{{ $userHandle := resolve $follow.UserDid }}
85
+
{{ $subjectHandle := resolve $follow.SubjectDid }}
86
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
87
+
{{ template "user/fragments/picHandleLink" $userHandle }}
88
+
followed
89
+
{{ template "user/fragments/picHandleLink" $subjectHandle }}
90
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
91
+
</div>
92
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
93
+
<div class="flex-shrink-0 max-h-full w-24 h-24">
94
+
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
95
+
</div>
96
+
97
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
98
+
<a href="/{{ $subjectHandle }}">
99
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
100
+
</a>
101
+
{{ with $profile }}
102
+
{{ with .Description }}
103
+
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
104
+
{{ end }}
105
+
{{ end }}
106
+
{{ with $stat }}
107
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
108
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
109
+
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
110
+
<span class="select-none after:content-['ยท']"></span>
111
+
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
112
+
</div>
113
+
{{ end }}
114
+
</div>
115
+
</div>
116
+
{{ end }}
+25
appview/pages/templates/timeline/fragments/trending.html
+25
appview/pages/templates/timeline/fragments/trending.html
···
1
+
{{ define "timeline/fragments/trending" }}
2
+
<div class="w-full md:mx-0 py-4">
3
+
<div class="px-6 pb-4">
4
+
<h3 class="text-xl font-bold dark:text-white flex items-center gap-2">
5
+
Trending
6
+
{{ i "trending-up" "size-4 flex-shrink-0" }}
7
+
</h3>
8
+
</div>
9
+
<div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch">
10
+
{{ range $index, $repo := .Repos }}
11
+
<div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96">
12
+
{{ template "user/fragments/repoCard" (list $ $repo true) }}
13
+
</div>
14
+
{{ else }}
15
+
<div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm">
16
+
<div class="text-sm text-gray-500 dark:text-gray-400 text-center">
17
+
No trending repositories this week
18
+
</div>
19
+
</div>
20
+
{{ end }}
21
+
</div>
22
+
</div>
23
+
{{ end }}
24
+
25
+
+90
appview/pages/templates/timeline/home.html
+90
appview/pages/templates/timeline/home.html
···
1
+
{{ define "title" }}tangled · tightly-knit social coding{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="timeline ยท tangled" />
5
+
<meta property="og:type" content="object" />
6
+
<meta property="og:url" content="https://tangled.sh" />
7
+
<meta property="og:description" content="tightly-knit social coding" />
8
+
{{ end }}
9
+
10
+
11
+
{{ define "content" }}
12
+
<div class="flex flex-col gap-4">
13
+
{{ template "timeline/fragments/hero" . }}
14
+
{{ template "features" . }}
15
+
{{ template "timeline/fragments/trending" . }}
16
+
{{ template "timeline/fragments/timeline" . }}
17
+
<div class="flex justify-end">
18
+
<a href="/timeline" class="inline-flex items-center gap-2 text-gray-500 dark:text-gray-400">
19
+
view more
20
+
{{ i "arrow-right" "size-4" }}
21
+
</a>
22
+
</div>
23
+
</div>
24
+
{{ end }}
25
+
26
+
27
+
{{ define "feature" }}
28
+
{{ $info := index . 0 }}
29
+
{{ $bullets := index . 1 }}
30
+
<div class="flex flex-col items-center gap-6 md:flex-row md:items-top">
31
+
<div class="flex-1">
32
+
<h2 class="text-2xl font-bold text-black dark:text-white mb-6">{{ $info.title }}</h2>
33
+
<ul class="leading-normal">
34
+
{{ range $bullets }}
35
+
<li><p>{{ escapeHtml . }}</p></li>
36
+
{{ end }}
37
+
</ul>
38
+
</div>
39
+
<div class="flex-shrink-0 w-96 md:w-1/3">
40
+
<a href="{{ $info.image }}">
41
+
<img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded shadow-sm" />
42
+
</a>
43
+
</div>
44
+
</div>
45
+
{{ end }}
46
+
47
+
{{ define "features" }}
48
+
<div class="prose dark:text-gray-200 space-y-12 px-6 py-4 bg-white dark:bg-gray-800 rounded drop-shadow-sm">
49
+
{{ template "feature" (list
50
+
(dict
51
+
"title" "lightweight git repo hosting"
52
+
"image" "https://assets.tangled.network/what-is-tangled-repo.png"
53
+
"alt" "A repository hosted on Tangled"
54
+
)
55
+
(list
56
+
"Host your repositories on your own infrastructure using <em>knots</em>—tiny, headless servers that facilitate git operations."
57
+
"Add friends to your knot or invite collaborators to your repository."
58
+
"Guarded by fine-grained role-based access control."
59
+
"Use SSH to push and pull."
60
+
)
61
+
) }}
62
+
63
+
{{ template "feature" (list
64
+
(dict
65
+
"title" "improved pull request model"
66
+
"image" "https://assets.tangled.network/pulls.png"
67
+
"alt" "Round-based pull requests."
68
+
)
69
+
(list
70
+
"An intuitive and effective round-based pull request flow, with inter-diffing between rounds."
71
+
"Stacked pull requests using Jujutsu's change IDs."
72
+
"Paste a <code>git diff</code> or <code>git format-patch</code> for quick drive-by changes."
73
+
)
74
+
) }}
75
+
76
+
{{ template "feature" (list
77
+
(dict
78
+
"title" "run pipelines using spindles"
79
+
"image" "https://assets.tangled.network/pipelines.png"
80
+
"alt" "CI pipeline running on spindle"
81
+
)
82
+
(list
83
+
"Run pipelines on your own infrastructure using <em>spindles</em>—lightweight CI runners."
84
+
"Natively supports Nix for package management."
85
+
"Easily extended to support different execution backends."
86
+
)
87
+
) }}
88
+
</div>
89
+
{{ end }}
90
+
+6
-171
appview/pages/templates/timeline/timeline.html
+6
-171
appview/pages/templates/timeline/timeline.html
···
8
8
{{ end }}
9
9
10
10
{{ define "content" }}
11
-
{{ if .LoggedInUser }}
12
-
{{ else }}
13
-
{{ block "hero" $ }}{{ end }}
14
-
{{ end }}
11
+
{{ if .LoggedInUser }}
12
+
{{ else }}
13
+
{{ template "timeline/fragments/hero" . }}
14
+
{{ end }}
15
15
16
-
{{ block "trending" $ }}{{ end }}
17
-
{{ block "timeline" $ }}{{ end }}
18
-
{{ end }}
19
-
20
-
{{ define "hero" }}
21
-
<div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl">
22
-
<div class="font-bold text-4xl">tightly-knit<br>social coding.</div>
23
-
24
-
<p class="text-lg">
25
-
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
26
-
</p>
27
-
<p class="text-lg">
28
-
we envision a place where developers have complete ownership of their
29
-
code, open source communities can freely self-govern and most
30
-
importantly, coding can be social and fun again.
31
-
</p>
32
-
33
-
<div class="flex gap-6 items-center">
34
-
<a href="/signup" class="no-underline hover:no-underline ">
35
-
<button class="btn-create flex gap-2 px-4 items-center">
36
-
join now {{ i "arrow-right" "size-4" }}
37
-
</button>
38
-
</a>
39
-
</div>
40
-
</div>
41
-
{{ end }}
42
-
43
-
{{ define "trending" }}
44
-
<div class="w-full md:mx-0 py-4">
45
-
<div class="px-6 pb-4">
46
-
<h3 class="text-xl font-bold dark:text-white flex items-center gap-2">
47
-
Trending
48
-
{{ i "trending-up" "size-4 flex-shrink-0" }}
49
-
</h3>
50
-
</div>
51
-
<div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch">
52
-
{{ range $index, $repo := .Repos }}
53
-
<div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96">
54
-
{{ template "user/fragments/repoCard" (list $ $repo true) }}
55
-
</div>
56
-
{{ else }}
57
-
<div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm">
58
-
<div class="text-sm text-gray-500 dark:text-gray-400 text-center">
59
-
No trending repositories this week
60
-
</div>
61
-
</div>
62
-
{{ end }}
63
-
</div>
64
-
</div>
65
-
{{ end }}
66
-
67
-
{{ define "timeline" }}
68
-
<div class="py-4">
69
-
<div class="px-6 pb-4">
70
-
<p class="text-xl font-bold dark:text-white">Timeline</p>
71
-
</div>
72
-
73
-
<div class="flex flex-col gap-4">
74
-
{{ range $i, $e := .Timeline }}
75
-
<div class="relative">
76
-
{{ if ne $i 0 }}
77
-
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
78
-
{{ end }}
79
-
{{ with $e }}
80
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
81
-
{{ if .Repo }}
82
-
{{ block "repoEvent" (list $ .Repo .Source) }} {{ end }}
83
-
{{ else if .Star }}
84
-
{{ block "starEvent" (list $ .Star) }} {{ end }}
85
-
{{ else if .Follow }}
86
-
{{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }}
87
-
{{ end }}
88
-
</div>
89
-
{{ end }}
90
-
</div>
91
-
{{ end }}
92
-
</div>
93
-
</div>
94
-
{{ end }}
95
-
96
-
{{ define "repoEvent" }}
97
-
{{ $root := index . 0 }}
98
-
{{ $repo := index . 1 }}
99
-
{{ $source := index . 2 }}
100
-
{{ $userHandle := resolve $repo.Did }}
101
-
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
102
-
{{ template "user/fragments/picHandleLink" $repo.Did }}
103
-
{{ with $source }}
104
-
{{ $sourceDid := resolve .Did }}
105
-
forked
106
-
<a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline">
107
-
{{ $sourceDid }}/{{ .Name }}
108
-
</a>
109
-
to
110
-
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a>
111
-
{{ else }}
112
-
created
113
-
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">
114
-
{{ $repo.Name }}
115
-
</a>
116
-
{{ end }}
117
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
118
-
</div>
119
-
{{ with $repo }}
120
-
{{ template "user/fragments/repoCard" (list $root . true) }}
121
-
{{ end }}
122
-
{{ end }}
123
-
124
-
{{ define "starEvent" }}
125
-
{{ $root := index . 0 }}
126
-
{{ $star := index . 1 }}
127
-
{{ with $star }}
128
-
{{ $starrerHandle := resolve .StarredByDid }}
129
-
{{ $repoOwnerHandle := resolve .Repo.Did }}
130
-
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
131
-
{{ template "user/fragments/picHandleLink" $starrerHandle }}
132
-
starred
133
-
<a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">
134
-
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
135
-
</a>
136
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
137
-
</div>
138
-
{{ with .Repo }}
139
-
{{ template "user/fragments/repoCard" (list $root . true) }}
140
-
{{ end }}
141
-
{{ end }}
142
-
{{ end }}
143
-
144
-
145
-
{{ define "followEvent" }}
146
-
{{ $root := index . 0 }}
147
-
{{ $follow := index . 1 }}
148
-
{{ $profile := index . 2 }}
149
-
{{ $stat := index . 3 }}
150
-
151
-
{{ $userHandle := resolve $follow.UserDid }}
152
-
{{ $subjectHandle := resolve $follow.SubjectDid }}
153
-
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
154
-
{{ template "user/fragments/picHandleLink" $userHandle }}
155
-
followed
156
-
{{ template "user/fragments/picHandleLink" $subjectHandle }}
157
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
158
-
</div>
159
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
160
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
161
-
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
162
-
</div>
163
-
164
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
165
-
<a href="/{{ $subjectHandle }}">
166
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
167
-
</a>
168
-
{{ with $profile }}
169
-
{{ with .Description }}
170
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
171
-
{{ end }}
172
-
{{ end }}
173
-
{{ with $stat }}
174
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
175
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
176
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
177
-
<span class="select-none after:content-['ยท']"></span>
178
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
179
-
</div>
180
-
{{ end }}
181
-
</div>
182
-
</div>
16
+
{{ template "timeline/fragments/trending" . }}
17
+
{{ template "timeline/fragments/timeline" . }}
183
18
{{ end }}
+2
-4
appview/pages/templates/user/completeSignup.html
+2
-4
appview/pages/templates/user/completeSignup.html
···
29
29
</head>
30
30
<body class="flex items-center justify-center min-h-screen">
31
31
<main class="max-w-md px-6 -mt-4">
32
-
<h1
33
-
class="text-center text-2xl font-semibold italic dark:text-white"
34
-
>
35
-
tangled
32
+
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
33
+
{{ template "fragments/logotype" }}
36
34
</h1>
37
35
<h2 class="text-center text-xl italic dark:text-white">
38
36
tightly-knit social coding.
+4
-16
appview/pages/templates/user/followers.html
+4
-16
appview/pages/templates/user/followers.html
···
1
1
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }}
2
2
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=followers" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
{{ template "user/fragments/profileCard" .Card }}
14
-
</div>
15
-
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
16
-
{{ block "followers" . }}{{ end }}
17
-
</div>
18
-
</div>
3
+
{{ define "profileContent" }}
4
+
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "followers" . }}{{ end }}
6
+
</div>
19
7
{{ end }}
20
8
21
9
{{ define "followers" }}
+4
-16
appview/pages/templates/user/following.html
+4
-16
appview/pages/templates/user/following.html
···
1
1
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }}
2
2
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=following" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
{{ template "user/fragments/profileCard" .Card }}
14
-
</div>
15
-
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
16
-
{{ block "following" . }}{{ end }}
17
-
</div>
18
-
</div>
3
+
{{ define "profileContent" }}
4
+
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "following" . }}{{ end }}
6
+
</div>
19
7
{{ end }}
20
8
21
9
{{ define "following" }}
+1
-1
appview/pages/templates/user/fragments/editBio.html
+1
-1
appview/pages/templates/user/fragments/editBio.html
+1
-1
appview/pages/templates/user/fragments/picHandle.html
+1
-1
appview/pages/templates/user/fragments/picHandle.html
+2
-4
appview/pages/templates/user/fragments/profileCard.html
+2
-4
appview/pages/templates/user/fragments/profileCard.html
···
1
1
{{ define "user/fragments/profileCard" }}
2
2
{{ $userIdent := didOrHandle .UserDid .UserHandle }}
3
-
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
4
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
5
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
6
5
<div class="w-3/4 aspect-square relative">
···
85
84
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
86
85
</div>
87
86
</div>
88
-
</div>
89
87
{{ end }}
90
88
91
89
{{ define "followerFollowing" }}
···
94
92
{{ with $root }}
95
93
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
96
94
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
97
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
95
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span>
98
96
<span class="select-none after:content-['ยท']"></span>
99
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
97
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span>
100
98
</div>
101
99
{{ end }}
102
100
{{ end }}
+1
-2
appview/pages/templates/user/fragments/repoCard.html
+1
-2
appview/pages/templates/user/fragments/repoCard.html
···
36
36
<div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto">
37
37
{{ with .Language }}
38
38
<div class="flex gap-2 items-center text-sm">
39
-
<div class="size-2 rounded-full"
40
-
style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div>
39
+
{{ template "repo/fragments/languageBall" . }}
41
40
<span>{{ . }}</span>
42
41
</div>
43
42
{{ end }}
+2
-2
appview/pages/templates/user/login.html
+2
-2
appview/pages/templates/user/login.html
···
13
13
</head>
14
14
<body class="flex items-center justify-center min-h-screen">
15
15
<main class="max-w-md px-6 -mt-4">
16
-
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >
17
-
tangled
16
+
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
17
+
{{ template "fragments/logotype" }}
18
18
</h1>
19
19
<h2 class="text-center text-xl italic dark:text-white">
20
20
tightly-knit social coding.
+269
appview/pages/templates/user/overview.html
+269
appview/pages/templates/user/overview.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
+
3
+
{{ define "profileContent" }}
4
+
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
5
+
<div class="grid grid-cols-1 gap-4">
6
+
{{ block "ownRepos" . }}{{ end }}
7
+
{{ block "collaboratingRepos" . }}{{ end }}
8
+
</div>
9
+
</div>
10
+
<div class="md:col-span-4 order-3 md:order-3">
11
+
{{ block "profileTimeline" . }}{{ end }}
12
+
</div>
13
+
{{ end }}
14
+
15
+
{{ define "profileTimeline" }}
16
+
<p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p>
17
+
<div class="flex flex-col gap-4 relative">
18
+
{{ if .ProfileTimeline.IsEmpty }}
19
+
<p class="dark:text-white">This user does not have any activity yet.</p>
20
+
{{ end }}
21
+
22
+
{{ with .ProfileTimeline }}
23
+
{{ range $idx, $byMonth := .ByMonth }}
24
+
{{ with $byMonth }}
25
+
{{ if not .IsEmpty }}
26
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm py-4 px-6">
27
+
<p class="text-sm font-mono mb-2 text-gray-500 dark:text-gray-400">
28
+
{{ if eq $idx 0 }}
29
+
this month
30
+
{{ else }}
31
+
{{$idx}} month{{if ne $idx 1}}s{{end}} ago
32
+
{{ end }}
33
+
</p>
34
+
35
+
<div class="flex flex-col gap-1">
36
+
{{ block "repoEvents" .RepoEvents }} {{ end }}
37
+
{{ block "issueEvents" .IssueEvents }} {{ end }}
38
+
{{ block "pullEvents" .PullEvents }} {{ end }}
39
+
</div>
40
+
</div>
41
+
{{ end }}
42
+
{{ end }}
43
+
{{ end }}
44
+
{{ end }}
45
+
</div>
46
+
{{ end }}
47
+
48
+
{{ define "repoEvents" }}
49
+
{{ if gt (len .) 0 }}
50
+
<details>
51
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
52
+
<div class="flex flex-wrap items-center gap-2">
53
+
{{ i "book-plus" "w-4 h-4" }}
54
+
created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}}
55
+
</div>
56
+
</summary>
57
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
58
+
{{ range . }}
59
+
<div class="flex flex-wrap items-center justify-between gap-2">
60
+
<span class="flex items-center gap-2">
61
+
<span class="text-gray-500 dark:text-gray-400">
62
+
{{ if .Source }}
63
+
{{ i "git-fork" "w-4 h-4" }}
64
+
{{ else }}
65
+
{{ i "book-plus" "w-4 h-4" }}
66
+
{{ end }}
67
+
</span>
68
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
69
+
{{- .Repo.Name -}}
70
+
</a>
71
+
</span>
72
+
73
+
{{ with .Repo.RepoStats }}
74
+
{{ with .Language }}
75
+
<div class="flex gap-2 items-center text-xs font-mono text-gray-400 ">
76
+
{{ template "repo/fragments/languageBall" . }}
77
+
<span>{{ . }}</span>
78
+
</div>
79
+
{{end }}
80
+
{{end }}
81
+
</div>
82
+
{{ end }}
83
+
</div>
84
+
</details>
85
+
{{ end }}
86
+
{{ end }}
87
+
88
+
{{ define "issueEvents" }}
89
+
{{ $items := .Items }}
90
+
{{ $stats := .Stats }}
91
+
92
+
{{ if gt (len $items) 0 }}
93
+
<details>
94
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
95
+
<div class="flex flex-wrap items-center gap-2">
96
+
{{ i "circle-dot" "w-4 h-4" }}
97
+
98
+
<div>
99
+
created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}}
100
+
</div>
101
+
102
+
{{ if gt $stats.Open 0 }}
103
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
104
+
{{$stats.Open}} open
105
+
</span>
106
+
{{ end }}
107
+
108
+
{{ if gt $stats.Closed 0 }}
109
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
110
+
{{$stats.Closed}} closed
111
+
</span>
112
+
{{ end }}
113
+
114
+
</div>
115
+
</summary>
116
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
117
+
{{ range $items }}
118
+
{{ $repoOwner := resolve .Repo.Did }}
119
+
{{ $repoName := .Repo.Name }}
120
+
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
121
+
122
+
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
123
+
{{ if .Open }}
124
+
<span class="text-green-600 dark:text-green-500">
125
+
{{ i "circle-dot" "w-4 h-4" }}
126
+
</span>
127
+
{{ else }}
128
+
<span class="text-gray-500 dark:text-gray-400">
129
+
{{ i "ban" "w-4 h-4" }}
130
+
</span>
131
+
{{ end }}
132
+
<div class="flex-none min-w-8 text-right">
133
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
134
+
</div>
135
+
<div class="break-words max-w-full">
136
+
<a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline">
137
+
{{ .Title -}}
138
+
</a>
139
+
on
140
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
141
+
{{$repoUrl}}
142
+
</a>
143
+
</div>
144
+
</div>
145
+
{{ end }}
146
+
</div>
147
+
</details>
148
+
{{ end }}
149
+
{{ end }}
150
+
151
+
{{ define "pullEvents" }}
152
+
{{ $items := .Items }}
153
+
{{ $stats := .Stats }}
154
+
{{ if gt (len $items) 0 }}
155
+
<details>
156
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
157
+
<div class="flex flex-wrap items-center gap-2">
158
+
{{ i "git-pull-request" "w-4 h-4" }}
159
+
160
+
<div>
161
+
created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}}
162
+
</div>
163
+
164
+
{{ if gt $stats.Open 0 }}
165
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
166
+
{{$stats.Open}} open
167
+
</span>
168
+
{{ end }}
169
+
170
+
{{ if gt $stats.Merged 0 }}
171
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700">
172
+
{{$stats.Merged}} merged
173
+
</span>
174
+
{{ end }}
175
+
176
+
177
+
{{ if gt $stats.Closed 0 }}
178
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
179
+
{{$stats.Closed}} closed
180
+
</span>
181
+
{{ end }}
182
+
183
+
</div>
184
+
</summary>
185
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
186
+
{{ range $items }}
187
+
{{ $repoOwner := resolve .Repo.Did }}
188
+
{{ $repoName := .Repo.Name }}
189
+
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
190
+
191
+
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
192
+
{{ if .State.IsOpen }}
193
+
<span class="text-green-600 dark:text-green-500">
194
+
{{ i "git-pull-request" "w-4 h-4" }}
195
+
</span>
196
+
{{ else if .State.IsMerged }}
197
+
<span class="text-purple-600 dark:text-purple-500">
198
+
{{ i "git-merge" "w-4 h-4" }}
199
+
</span>
200
+
{{ else }}
201
+
<span class="text-gray-600 dark:text-gray-300">
202
+
{{ i "git-pull-request-closed" "w-4 h-4" }}
203
+
</span>
204
+
{{ end }}
205
+
<div class="flex-none min-w-8 text-right">
206
+
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
207
+
</div>
208
+
<div class="break-words max-w-full">
209
+
<a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline">
210
+
{{ .Title -}}
211
+
</a>
212
+
on
213
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
214
+
{{$repoUrl}}
215
+
</a>
216
+
</div>
217
+
</div>
218
+
{{ end }}
219
+
</div>
220
+
</details>
221
+
{{ end }}
222
+
{{ end }}
223
+
224
+
{{ define "ownRepos" }}
225
+
<div>
226
+
<div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2">
227
+
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos"
228
+
class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group">
229
+
<span>PINNED REPOS</span>
230
+
</a>
231
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }}
232
+
<button
233
+
hx-get="profile/edit-pins"
234
+
hx-target="#all-repos"
235
+
class="py-0 font-normal text-sm flex gap-2 items-center group">
236
+
{{ i "pencil" "w-3 h-3" }}
237
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
238
+
</button>
239
+
{{ end }}
240
+
</div>
241
+
<div id="repos" class="grid grid-cols-1 gap-4 items-stretch">
242
+
{{ range .Repos }}
243
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
244
+
{{ template "user/fragments/repoCard" (list $ . false) }}
245
+
</div>
246
+
{{ else }}
247
+
<p class="dark:text-white">This user does not have any pinned repos.</p>
248
+
{{ end }}
249
+
</div>
250
+
</div>
251
+
{{ end }}
252
+
253
+
{{ define "collaboratingRepos" }}
254
+
{{ if gt (len .CollaboratingRepos) 0 }}
255
+
<div>
256
+
<p class="text-sm font-bold px-2 pb-4 dark:text-white">COLLABORATING ON</p>
257
+
<div id="collaborating" class="grid grid-cols-1 gap-4">
258
+
{{ range .CollaboratingRepos }}
259
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
260
+
{{ template "user/fragments/repoCard" (list $ . true) }}
261
+
</div>
262
+
{{ else }}
263
+
<p class="px-6 dark:text-white">This user is not collaborating.</p>
264
+
{{ end }}
265
+
</div>
266
+
</div>
267
+
{{ end }}
268
+
{{ end }}
269
+
-318
appview/pages/templates/user/profile.html
-318
appview/pages/templates/user/profile.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
-
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
-
<meta property="og:type" content="profile" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
<div class="grid grid-cols-1 gap-4">
14
-
{{ template "user/fragments/profileCard" .Card }}
15
-
{{ block "punchcard" .Punchcard }} {{ end }}
16
-
</div>
17
-
</div>
18
-
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
19
-
<div class="grid grid-cols-1 gap-4">
20
-
{{ block "ownRepos" . }}{{ end }}
21
-
{{ block "collaboratingRepos" . }}{{ end }}
22
-
</div>
23
-
</div>
24
-
<div class="md:col-span-4 order-3 md:order-3">
25
-
{{ block "profileTimeline" . }}{{ end }}
26
-
</div>
27
-
</div>
28
-
{{ end }}
29
-
30
-
{{ define "profileTimeline" }}
31
-
<p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p>
32
-
<div class="flex flex-col gap-4 relative">
33
-
{{ with .ProfileTimeline }}
34
-
{{ range $idx, $byMonth := .ByMonth }}
35
-
{{ with $byMonth }}
36
-
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm">
37
-
{{ if eq $idx 0 }}
38
-
39
-
{{ else }}
40
-
{{ $s := "s" }}
41
-
{{ if eq $idx 1 }}
42
-
{{ $s = "" }}
43
-
{{ end }}
44
-
<p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p>
45
-
{{ end }}
46
-
47
-
{{ if .IsEmpty }}
48
-
<div class="text-gray-500 dark:text-gray-400">
49
-
No activity for this month
50
-
</div>
51
-
{{ else }}
52
-
<div class="flex flex-col gap-1">
53
-
{{ block "repoEvents" .RepoEvents }} {{ end }}
54
-
{{ block "issueEvents" .IssueEvents }} {{ end }}
55
-
{{ block "pullEvents" .PullEvents }} {{ end }}
56
-
</div>
57
-
{{ end }}
58
-
</div>
59
-
60
-
{{ end }}
61
-
{{ else }}
62
-
<p class="dark:text-white">This user does not have any activity yet.</p>
63
-
{{ end }}
64
-
{{ end }}
65
-
</div>
66
-
{{ end }}
67
-
68
-
{{ define "repoEvents" }}
69
-
{{ if gt (len .) 0 }}
70
-
<details>
71
-
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
72
-
<div class="flex flex-wrap items-center gap-2">
73
-
{{ i "book-plus" "w-4 h-4" }}
74
-
created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}}
75
-
</div>
76
-
</summary>
77
-
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
78
-
{{ range . }}
79
-
<div class="flex flex-wrap items-center gap-2">
80
-
<span class="text-gray-500 dark:text-gray-400">
81
-
{{ if .Source }}
82
-
{{ i "git-fork" "w-4 h-4" }}
83
-
{{ else }}
84
-
{{ i "book-plus" "w-4 h-4" }}
85
-
{{ end }}
86
-
</span>
87
-
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
88
-
{{- .Repo.Name -}}
89
-
</a>
90
-
</div>
91
-
{{ end }}
92
-
</div>
93
-
</details>
94
-
{{ end }}
95
-
{{ end }}
96
-
97
-
{{ define "issueEvents" }}
98
-
{{ $items := .Items }}
99
-
{{ $stats := .Stats }}
100
-
101
-
{{ if gt (len $items) 0 }}
102
-
<details>
103
-
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
104
-
<div class="flex flex-wrap items-center gap-2">
105
-
{{ i "circle-dot" "w-4 h-4" }}
106
-
107
-
<div>
108
-
created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}}
109
-
</div>
110
-
111
-
{{ if gt $stats.Open 0 }}
112
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
113
-
{{$stats.Open}} open
114
-
</span>
115
-
{{ end }}
116
-
117
-
{{ if gt $stats.Closed 0 }}
118
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
119
-
{{$stats.Closed}} closed
120
-
</span>
121
-
{{ end }}
122
-
123
-
</div>
124
-
</summary>
125
-
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
126
-
{{ range $items }}
127
-
{{ $repoOwner := resolve .Metadata.Repo.Did }}
128
-
{{ $repoName := .Metadata.Repo.Name }}
129
-
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
130
-
131
-
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
132
-
{{ if .Open }}
133
-
<span class="text-green-600 dark:text-green-500">
134
-
{{ i "circle-dot" "w-4 h-4" }}
135
-
</span>
136
-
{{ else }}
137
-
<span class="text-gray-500 dark:text-gray-400">
138
-
{{ i "ban" "w-4 h-4" }}
139
-
</span>
140
-
{{ end }}
141
-
<div class="flex-none min-w-8 text-right">
142
-
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
143
-
</div>
144
-
<div class="break-words max-w-full">
145
-
<a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline">
146
-
{{ .Title -}}
147
-
</a>
148
-
on
149
-
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
150
-
{{$repoUrl}}
151
-
</a>
152
-
</div>
153
-
</div>
154
-
{{ end }}
155
-
</div>
156
-
</details>
157
-
{{ end }}
158
-
{{ end }}
159
-
160
-
{{ define "pullEvents" }}
161
-
{{ $items := .Items }}
162
-
{{ $stats := .Stats }}
163
-
{{ if gt (len $items) 0 }}
164
-
<details>
165
-
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
166
-
<div class="flex flex-wrap items-center gap-2">
167
-
{{ i "git-pull-request" "w-4 h-4" }}
168
-
169
-
<div>
170
-
created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}}
171
-
</div>
172
-
173
-
{{ if gt $stats.Open 0 }}
174
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
175
-
{{$stats.Open}} open
176
-
</span>
177
-
{{ end }}
178
-
179
-
{{ if gt $stats.Merged 0 }}
180
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700">
181
-
{{$stats.Merged}} merged
182
-
</span>
183
-
{{ end }}
184
-
185
-
186
-
{{ if gt $stats.Closed 0 }}
187
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
188
-
{{$stats.Closed}} closed
189
-
</span>
190
-
{{ end }}
191
-
192
-
</div>
193
-
</summary>
194
-
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
195
-
{{ range $items }}
196
-
{{ $repoOwner := resolve .Repo.Did }}
197
-
{{ $repoName := .Repo.Name }}
198
-
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
199
-
200
-
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
201
-
{{ if .State.IsOpen }}
202
-
<span class="text-green-600 dark:text-green-500">
203
-
{{ i "git-pull-request" "w-4 h-4" }}
204
-
</span>
205
-
{{ else if .State.IsMerged }}
206
-
<span class="text-purple-600 dark:text-purple-500">
207
-
{{ i "git-merge" "w-4 h-4" }}
208
-
</span>
209
-
{{ else }}
210
-
<span class="text-gray-600 dark:text-gray-300">
211
-
{{ i "git-pull-request-closed" "w-4 h-4" }}
212
-
</span>
213
-
{{ end }}
214
-
<div class="flex-none min-w-8 text-right">
215
-
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
216
-
</div>
217
-
<div class="break-words max-w-full">
218
-
<a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline">
219
-
{{ .Title -}}
220
-
</a>
221
-
on
222
-
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
223
-
{{$repoUrl}}
224
-
</a>
225
-
</div>
226
-
</div>
227
-
{{ end }}
228
-
</div>
229
-
</details>
230
-
{{ end }}
231
-
{{ end }}
232
-
233
-
{{ define "ownRepos" }}
234
-
<div>
235
-
<div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2">
236
-
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos"
237
-
class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group">
238
-
<span>PINNED REPOS</span>
239
-
<span class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 ">
240
-
view all {{ i "chevron-right" "w-4 h-4" }}
241
-
</span>
242
-
</a>
243
-
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }}
244
-
<button
245
-
hx-get="profile/edit-pins"
246
-
hx-target="#all-repos"
247
-
class="btn py-0 font-normal text-sm flex gap-2 items-center group">
248
-
{{ i "pencil" "w-3 h-3" }}
249
-
edit
250
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
251
-
</button>
252
-
{{ end }}
253
-
</div>
254
-
<div id="repos" class="grid grid-cols-1 gap-4 items-stretch">
255
-
{{ range .Repos }}
256
-
{{ template "user/fragments/repoCard" (list $ . false) }}
257
-
{{ else }}
258
-
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
259
-
{{ end }}
260
-
</div>
261
-
</div>
262
-
{{ end }}
263
-
264
-
{{ define "collaboratingRepos" }}
265
-
{{ if gt (len .CollaboratingRepos) 0 }}
266
-
<div>
267
-
<p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p>
268
-
<div id="collaborating" class="grid grid-cols-1 gap-4">
269
-
{{ range .CollaboratingRepos }}
270
-
{{ template "user/fragments/repoCard" (list $ . true) }}
271
-
{{ else }}
272
-
<p class="px-6 dark:text-white">This user is not collaborating.</p>
273
-
{{ end }}
274
-
</div>
275
-
</div>
276
-
{{ end }}
277
-
{{ end }}
278
-
279
-
{{ define "punchcard" }}
280
-
{{ $now := now }}
281
-
<div>
282
-
<p class="p-2 flex gap-2 text-sm font-bold dark:text-white">
283
-
PUNCHCARD
284
-
<span class="font-normal text-sm text-gray-500 dark:text-gray-400 ">
285
-
{{ .Total | int64 | commaFmt }} commits
286
-
</span>
287
-
</p>
288
-
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm">
289
-
<div class="grid grid-cols-28 md:grid-cols-14 gap-y-2 w-full h-full">
290
-
{{ range .Punches }}
291
-
{{ $count := .Count }}
292
-
{{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
293
-
{{ if lt $count 1 }}
294
-
{{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
295
-
{{ else if lt $count 2 }}
296
-
{{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }}
297
-
{{ else if lt $count 4 }}
298
-
{{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }}
299
-
{{ else if lt $count 8 }}
300
-
{{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }}
301
-
{{ else }}
302
-
{{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }}
303
-
{{ end }}
304
-
305
-
{{ if .Date.After $now }}
306
-
{{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }}
307
-
{{ end }}
308
-
<div class="w-full h-full flex justify-center items-center">
309
-
<div
310
-
class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full"
311
-
title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits">
312
-
</div>
313
-
</div>
314
-
{{ end }}
315
-
</div>
316
-
</div>
317
-
</div>
318
-
{{ end }}
+7
-18
appview/pages/templates/user/repos.html
+7
-18
appview/pages/templates/user/repos.html
···
1
1
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
2
2
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
{{ template "user/fragments/profileCard" .Card }}
14
-
</div>
15
-
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
16
-
{{ block "ownRepos" . }}{{ end }}
17
-
</div>
18
-
</div>
3
+
{{ define "profileContent" }}
4
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "ownRepos" . }}{{ end }}
6
+
</div>
19
7
{{ end }}
20
8
21
9
{{ define "ownRepos" }}
22
-
<p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p>
23
10
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
24
11
{{ range .Repos }}
25
-
{{ template "user/fragments/repoCard" (list $ . false) }}
12
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
13
+
{{ template "user/fragments/repoCard" (list $ . false) }}
14
+
</div>
26
15
{{ else }}
27
16
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
28
17
{{ end }}
+2
-2
appview/pages/templates/user/settings/emails.html
+2
-2
appview/pages/templates/user/settings/emails.html
···
4
4
<div class="p-6">
5
5
<p class="text-xl font-bold dark:text-white">Settings</p>
6
6
</div>
7
-
<div class="bg-white dark:bg-gray-800">
8
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
9
<div class="col-span-1">
10
10
{{ template "user/settings/fragments/sidebar" . }}
11
11
</div>
+2
-2
appview/pages/templates/user/settings/keys.html
+2
-2
appview/pages/templates/user/settings/keys.html
···
4
4
<div class="p-6">
5
5
<p class="text-xl font-bold dark:text-white">Settings</p>
6
6
</div>
7
-
<div class="bg-white dark:bg-gray-800">
8
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
9
<div class="col-span-1">
10
10
{{ template "user/settings/fragments/sidebar" . }}
11
11
</div>
+2
-2
appview/pages/templates/user/settings/profile.html
+2
-2
appview/pages/templates/user/settings/profile.html
···
4
4
<div class="p-6">
5
5
<p class="text-xl font-bold dark:text-white">Settings</p>
6
6
</div>
7
-
<div class="bg-white dark:bg-gray-800">
8
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
9
<div class="col-span-1">
10
10
{{ template "user/settings/fragments/sidebar" . }}
11
11
</div>
+3
-1
appview/pages/templates/user/signup.html
+3
-1
appview/pages/templates/user/signup.html
···
13
13
</head>
14
14
<body class="flex items-center justify-center min-h-screen">
15
15
<main class="max-w-md px-6 -mt-4">
16
-
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1>
16
+
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
17
+
{{ template "fragments/logotype" }}
18
+
</h1>
17
19
<h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2>
18
20
<form
19
21
class="mt-4 max-w-sm mx-auto"
+19
appview/pages/templates/user/starred.html
+19
appview/pages/templates/user/starred.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
2
+
3
+
{{ define "profileContent" }}
4
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "starredRepos" . }}{{ end }}
6
+
</div>
7
+
{{ end }}
8
+
9
+
{{ define "starredRepos" }}
10
+
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
11
+
{{ range .Repos }}
12
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
13
+
{{ template "user/fragments/repoCard" (list $ . true) }}
14
+
</div>
15
+
{{ else }}
16
+
<p class="px-6 dark:text-white">This user does not have any starred repos yet.</p>
17
+
{{ end }}
18
+
</div>
19
+
{{ end }}
+45
appview/pages/templates/user/strings.html
+45
appview/pages/templates/user/strings.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }}
2
+
3
+
{{ define "profileContent" }}
4
+
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "allStrings" . }}{{ end }}
6
+
</div>
7
+
{{ end }}
8
+
9
+
{{ define "allStrings" }}
10
+
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
11
+
{{ range .Strings }}
12
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
13
+
{{ template "singleString" (list $ .) }}
14
+
</div>
15
+
{{ else }}
16
+
<p class="px-6 dark:text-white">This user does not have any strings yet.</p>
17
+
{{ end }}
18
+
</div>
19
+
{{ end }}
20
+
21
+
{{ define "singleString" }}
22
+
{{ $root := index . 0 }}
23
+
{{ $s := index . 1 }}
24
+
<div class="py-4 px-6 rounded bg-white dark:bg-gray-800">
25
+
<div class="font-medium dark:text-white flex gap-2 items-center">
26
+
<a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
27
+
</div>
28
+
{{ with $s.Description }}
29
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
30
+
{{ . }}
31
+
</div>
32
+
{{ end }}
33
+
34
+
{{ $stat := $s.Stats }}
35
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto">
36
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
37
+
<span class="select-none [&:before]:content-['ยท']"></span>
38
+
{{ with $s.Edited }}
39
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
40
+
{{ else }}
41
+
{{ template "repo/fragments/shortTimeAgo" $s.Created }}
42
+
{{ end }}
43
+
</div>
44
+
</div>
45
+
{{ end }}
+1
-1
appview/posthog/notifier.go
+1
-1
appview/posthog/notifier.go
···
58
58
59
59
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
60
60
err := n.client.Enqueue(posthog.Capture{
61
-
DistinctId: issue.OwnerDid,
61
+
DistinctId: issue.Did,
62
62
Event: "new_issue",
63
63
Properties: posthog.Properties{
64
64
"repo_at": issue.RepoAt.String(),
+230
-85
appview/pulls/pulls.go
+230
-85
appview/pulls/pulls.go
···
2
2
3
3
import (
4
4
"database/sql"
5
+
"encoding/json"
5
6
"errors"
6
7
"fmt"
7
8
"log"
···
21
22
"tangled.sh/tangled.sh/core/appview/reporesolver"
22
23
"tangled.sh/tangled.sh/core/appview/xrpcclient"
23
24
"tangled.sh/tangled.sh/core/idresolver"
24
-
"tangled.sh/tangled.sh/core/knotclient"
25
25
"tangled.sh/tangled.sh/core/patchutil"
26
26
"tangled.sh/tangled.sh/core/tid"
27
27
"tangled.sh/tangled.sh/core/types"
···
99
99
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
100
100
resubmitResult := pages.Unknown
101
101
if user.Did == pull.OwnerDid {
102
-
resubmitResult = s.resubmitCheck(f, pull, stack)
102
+
resubmitResult = s.resubmitCheck(r, f, pull, stack)
103
103
}
104
104
105
105
s.pages.PullActionsFragment(w, pages.PullActionsParams{
···
154
154
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
155
155
resubmitResult := pages.Unknown
156
156
if user != nil && user.Did == pull.OwnerDid {
157
-
resubmitResult = s.resubmitCheck(f, pull, stack)
157
+
resubmitResult = s.resubmitCheck(r, f, pull, stack)
158
158
}
159
159
160
160
repoInfo := f.RepoInfo(user)
···
282
282
return result
283
283
}
284
284
285
-
func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
285
+
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
286
286
if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil {
287
287
return pages.Unknown
288
288
}
···
307
307
repoName = f.Name
308
308
}
309
309
310
-
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
311
-
if err != nil {
312
-
log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
313
-
return pages.Unknown
310
+
scheme := "http"
311
+
if !s.config.Core.Dev {
312
+
scheme = "https"
313
+
}
314
+
host := fmt.Sprintf("%s://%s", scheme, knot)
315
+
xrpcc := &indigoxrpc.Client{
316
+
Host: host,
314
317
}
315
318
316
-
result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
319
+
repo := fmt.Sprintf("%s/%s", ownerDid, repoName)
320
+
branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo)
317
321
if err != nil {
322
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
323
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
324
+
return pages.Unknown
325
+
}
318
326
log.Println("failed to reach knotserver", err)
319
327
return pages.Unknown
320
328
}
321
329
330
+
targetBranch := branchResp
331
+
322
332
latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev
323
333
324
334
if pull.IsStacked() && stack != nil {
···
326
336
latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev
327
337
}
328
338
329
-
if latestSourceRev != result.Branch.Hash {
339
+
if latestSourceRev != targetBranch.Hash {
330
340
return pages.ShouldResubmit
331
341
}
332
342
···
678
688
679
689
switch r.Method {
680
690
case http.MethodGet:
681
-
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
691
+
scheme := "http"
692
+
if !s.config.Core.Dev {
693
+
scheme = "https"
694
+
}
695
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
696
+
xrpcc := &indigoxrpc.Client{
697
+
Host: host,
698
+
}
699
+
700
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
701
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
682
702
if err != nil {
683
-
log.Printf("failed to create unsigned client for %s", f.Knot)
684
-
s.pages.Error503(w)
703
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
704
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
705
+
s.pages.Error503(w)
706
+
return
707
+
}
708
+
log.Println("failed to fetch branches", err)
685
709
return
686
710
}
687
711
688
-
result, err := us.Branches(f.OwnerDid(), f.Name)
689
-
if err != nil {
690
-
log.Println("failed to fetch branches", err)
712
+
var result types.RepoBranchesResponse
713
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
714
+
log.Println("failed to decode XRPC response", err)
715
+
s.pages.Error503(w)
691
716
return
692
717
}
693
718
···
752
777
return
753
778
}
754
779
755
-
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
756
-
if err != nil {
757
-
log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
758
-
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
759
-
return
760
-
}
780
+
// us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
781
+
// if err != nil {
782
+
// log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
783
+
// s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
784
+
// return
785
+
// }
761
786
762
-
caps, err := us.Capabilities()
763
-
if err != nil {
764
-
log.Println("error fetching knot caps", f.Knot, err)
765
-
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
766
-
return
787
+
// TODO: make capabilities an xrpc call
788
+
caps := struct {
789
+
PullRequests struct {
790
+
FormatPatch bool
791
+
BranchSubmissions bool
792
+
ForkSubmissions bool
793
+
PatchSubmissions bool
794
+
}
795
+
}{
796
+
PullRequests: struct {
797
+
FormatPatch bool
798
+
BranchSubmissions bool
799
+
ForkSubmissions bool
800
+
PatchSubmissions bool
801
+
}{
802
+
FormatPatch: true,
803
+
BranchSubmissions: true,
804
+
ForkSubmissions: true,
805
+
PatchSubmissions: true,
806
+
},
767
807
}
808
+
809
+
// caps, err := us.Capabilities()
810
+
// if err != nil {
811
+
// log.Println("error fetching knot caps", f.Knot, err)
812
+
// s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
813
+
// return
814
+
// }
768
815
769
816
if !caps.PullRequests.FormatPatch {
770
817
s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
···
806
853
sourceBranch string,
807
854
isStacked bool,
808
855
) {
809
-
// Generate a patch using /compare
810
-
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
811
-
if err != nil {
812
-
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
813
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
814
-
return
856
+
scheme := "http"
857
+
if !s.config.Core.Dev {
858
+
scheme = "https"
859
+
}
860
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
861
+
xrpcc := &indigoxrpc.Client{
862
+
Host: host,
815
863
}
816
864
817
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch)
865
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
866
+
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch)
818
867
if err != nil {
868
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
869
+
log.Println("failed to call XRPC repo.compare", xrpcerr)
870
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
871
+
return
872
+
}
819
873
log.Println("failed to compare", err)
820
874
s.pages.Notice(w, "pull", err.Error())
875
+
return
876
+
}
877
+
878
+
var comparison types.RepoFormatPatchResponse
879
+
if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
880
+
log.Println("failed to decode XRPC compare response", err)
881
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
821
882
return
822
883
}
823
884
···
869
930
oauth.WithLxm(tangled.RepoHiddenRefNSID),
870
931
oauth.WithDev(s.config.Core.Dev),
871
932
)
872
-
if err != nil {
873
-
log.Printf("failed to connect to knot server: %v", err)
874
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
875
-
return
876
-
}
877
-
878
-
us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
879
-
if err != nil {
880
-
log.Println("failed to create unsigned client:", err)
881
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
882
-
return
883
-
}
884
933
885
934
resp, err := tangled.RepoHiddenRef(
886
935
r.Context(),
···
911
960
// hiddenRef: hidden/feature-1/main (on repo-fork)
912
961
// targetBranch: main (on repo-1)
913
962
// sourceBranch: feature-1 (on repo-fork)
914
-
comparison, err := us.Compare(fork.Did, fork.Name, hiddenRef, sourceBranch)
963
+
forkScheme := "http"
964
+
if !s.config.Core.Dev {
965
+
forkScheme = "https"
966
+
}
967
+
forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot)
968
+
forkXrpcc := &indigoxrpc.Client{
969
+
Host: forkHost,
970
+
}
971
+
972
+
forkRepoId := fmt.Sprintf("%s/%s", fork.Did, fork.Name)
973
+
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, forkRepoId, hiddenRef, sourceBranch)
915
974
if err != nil {
975
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
976
+
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
977
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
978
+
return
979
+
}
916
980
log.Println("failed to compare across branches", err)
917
981
s.pages.Notice(w, "pull", err.Error())
918
982
return
919
983
}
920
984
985
+
var comparison types.RepoFormatPatchResponse
986
+
if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil {
987
+
log.Println("failed to decode XRPC compare response for fork", err)
988
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
989
+
return
990
+
}
991
+
921
992
sourceRev := comparison.Rev2
922
993
patch := comparison.Patch
923
994
···
1211
1282
return
1212
1283
}
1213
1284
1214
-
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1285
+
scheme := "http"
1286
+
if !s.config.Core.Dev {
1287
+
scheme = "https"
1288
+
}
1289
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1290
+
xrpcc := &indigoxrpc.Client{
1291
+
Host: host,
1292
+
}
1293
+
1294
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1295
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1215
1296
if err != nil {
1216
-
log.Printf("failed to create unsigned client for %s", f.Knot)
1217
-
s.pages.Error503(w)
1297
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1298
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
1299
+
s.pages.Error503(w)
1300
+
return
1301
+
}
1302
+
log.Println("failed to fetch branches", err)
1218
1303
return
1219
1304
}
1220
1305
1221
-
result, err := us.Branches(f.OwnerDid(), f.Name)
1222
-
if err != nil {
1223
-
log.Println("failed to reach knotserver", err)
1306
+
var result types.RepoBranchesResponse
1307
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1308
+
log.Println("failed to decode XRPC response", err)
1309
+
s.pages.Error503(w)
1224
1310
return
1225
1311
}
1226
1312
···
1284
1370
return
1285
1371
}
1286
1372
1287
-
sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev)
1373
+
sourceScheme := "http"
1374
+
if !s.config.Core.Dev {
1375
+
sourceScheme = "https"
1376
+
}
1377
+
sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot)
1378
+
sourceXrpcc := &indigoxrpc.Client{
1379
+
Host: sourceHost,
1380
+
}
1381
+
1382
+
sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name)
1383
+
sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo)
1288
1384
if err != nil {
1289
-
log.Printf("failed to create unsigned client for %s", repo.Knot)
1290
-
s.pages.Error503(w)
1385
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1386
+
log.Println("failed to call XRPC repo.branches for source", xrpcerr)
1387
+
s.pages.Error503(w)
1388
+
return
1389
+
}
1390
+
log.Println("failed to fetch source branches", err)
1291
1391
return
1292
1392
}
1293
1393
1294
-
sourceResult, err := sourceBranchesClient.Branches(forkOwnerDid, repo.Name)
1295
-
if err != nil {
1296
-
log.Println("failed to reach knotserver for source branches", err)
1394
+
// Decode source branches
1395
+
var sourceBranches types.RepoBranchesResponse
1396
+
if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil {
1397
+
log.Println("failed to decode source branches XRPC response", err)
1398
+
s.pages.Error503(w)
1297
1399
return
1298
1400
}
1299
1401
1300
-
targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1402
+
targetScheme := "http"
1403
+
if !s.config.Core.Dev {
1404
+
targetScheme = "https"
1405
+
}
1406
+
targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot)
1407
+
targetXrpcc := &indigoxrpc.Client{
1408
+
Host: targetHost,
1409
+
}
1410
+
1411
+
targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1412
+
targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo)
1301
1413
if err != nil {
1302
-
log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1303
-
s.pages.Error503(w)
1414
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1415
+
log.Println("failed to call XRPC repo.branches for target", xrpcerr)
1416
+
s.pages.Error503(w)
1417
+
return
1418
+
}
1419
+
log.Println("failed to fetch target branches", err)
1304
1420
return
1305
1421
}
1306
1422
1307
-
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name)
1308
-
if err != nil {
1309
-
log.Println("failed to reach knotserver for target branches", err)
1423
+
// Decode target branches
1424
+
var targetBranches types.RepoBranchesResponse
1425
+
if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil {
1426
+
log.Println("failed to decode target branches XRPC response", err)
1427
+
s.pages.Error503(w)
1310
1428
return
1311
1429
}
1312
1430
1313
-
sourceBranches := sourceResult.Branches
1314
-
sort.Slice(sourceBranches, func(i int, j int) bool {
1315
-
return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
1431
+
sort.Slice(sourceBranches.Branches, func(i int, j int) bool {
1432
+
return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When)
1316
1433
})
1317
1434
1318
1435
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1319
1436
RepoInfo: f.RepoInfo(user),
1320
-
SourceBranches: sourceBranches,
1321
-
TargetBranches: targetResult.Branches,
1437
+
SourceBranches: sourceBranches.Branches,
1438
+
TargetBranches: targetBranches.Branches,
1322
1439
})
1323
1440
}
1324
1441
···
1413
1530
return
1414
1531
}
1415
1532
1416
-
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1417
-
if err != nil {
1418
-
log.Printf("failed to create client for %s: %s", f.Knot, err)
1419
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1420
-
return
1533
+
scheme := "http"
1534
+
if !s.config.Core.Dev {
1535
+
scheme = "https"
1536
+
}
1537
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1538
+
xrpcc := &indigoxrpc.Client{
1539
+
Host: host,
1421
1540
}
1422
1541
1423
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch)
1542
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1543
+
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch)
1424
1544
if err != nil {
1545
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1546
+
log.Println("failed to call XRPC repo.compare", xrpcerr)
1547
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1548
+
return
1549
+
}
1425
1550
log.Printf("compare request failed: %s", err)
1426
1551
s.pages.Notice(w, "resubmit-error", err.Error())
1552
+
return
1553
+
}
1554
+
1555
+
var comparison types.RepoFormatPatchResponse
1556
+
if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
1557
+
log.Println("failed to decode XRPC compare response", err)
1558
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1427
1559
return
1428
1560
}
1429
1561
···
1463
1595
}
1464
1596
1465
1597
// extract patch by performing compare
1466
-
ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev)
1598
+
forkScheme := "http"
1599
+
if !s.config.Core.Dev {
1600
+
forkScheme = "https"
1601
+
}
1602
+
forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1603
+
forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1604
+
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch)
1467
1605
if err != nil {
1468
-
log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1606
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1607
+
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1608
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1609
+
return
1610
+
}
1611
+
log.Printf("failed to compare branches: %s", err)
1612
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1613
+
return
1614
+
}
1615
+
1616
+
var forkComparison types.RepoFormatPatchResponse
1617
+
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1618
+
log.Println("failed to decode XRPC compare response for fork", err)
1469
1619
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1470
1620
return
1471
1621
}
···
1501
1651
return
1502
1652
}
1503
1653
1504
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1505
-
comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1506
-
if err != nil {
1507
-
log.Printf("failed to compare branches: %s", err)
1508
-
s.pages.Notice(w, "resubmit-error", err.Error())
1509
-
return
1510
-
}
1654
+
// Use the fork comparison we already made
1655
+
comparison := forkComparison
1511
1656
1512
1657
sourceRev := comparison.Rev2
1513
1658
patch := comparison.Patch
+26
-8
appview/repo/artifact.go
+26
-8
appview/repo/artifact.go
···
1
1
package repo
2
2
3
3
import (
4
+
"context"
5
+
"encoding/json"
4
6
"fmt"
5
7
"log"
6
8
"net/http"
···
9
11
10
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
13
lexutil "github.com/bluesky-social/indigo/lex/util"
14
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
12
15
"github.com/dustin/go-humanize"
13
16
"github.com/go-chi/chi/v5"
14
17
"github.com/go-git/go-git/v5/plumbing"
···
17
20
"tangled.sh/tangled.sh/core/appview/db"
18
21
"tangled.sh/tangled.sh/core/appview/pages"
19
22
"tangled.sh/tangled.sh/core/appview/reporesolver"
20
-
"tangled.sh/tangled.sh/core/knotclient"
23
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
21
24
"tangled.sh/tangled.sh/core/tid"
22
25
"tangled.sh/tangled.sh/core/types"
23
26
)
···
33
36
return
34
37
}
35
38
36
-
tag, err := rp.resolveTag(f, tagParam)
39
+
tag, err := rp.resolveTag(r.Context(), f, tagParam)
37
40
if err != nil {
38
41
log.Println("failed to resolve tag", err)
39
42
rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution")
···
140
143
return
141
144
}
142
145
143
-
tag, err := rp.resolveTag(f, tagParam)
146
+
tag, err := rp.resolveTag(r.Context(), f, tagParam)
144
147
if err != nil {
145
148
log.Println("failed to resolve tag", err)
146
149
rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution")
···
259
262
w.Write([]byte{})
260
263
}
261
264
262
-
func (rp *Repo) resolveTag(f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) {
265
+
func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) {
263
266
tagParam, err := url.QueryUnescape(tagParam)
264
267
if err != nil {
265
268
return nil, err
266
269
}
267
270
268
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
269
-
if err != nil {
270
-
return nil, err
271
+
scheme := "http"
272
+
if !rp.config.Core.Dev {
273
+
scheme = "https"
274
+
}
275
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
276
+
xrpcc := &indigoxrpc.Client{
277
+
Host: host,
271
278
}
272
279
273
-
result, err := us.Tags(f.OwnerDid(), f.Name)
280
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
281
+
xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
274
282
if err != nil {
283
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
284
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
285
+
return nil, xrpcerr
286
+
}
275
287
log.Println("failed to reach knotserver", err)
288
+
return nil, err
289
+
}
290
+
291
+
var result types.RepoTagsResponse
292
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
293
+
log.Println("failed to decode XRPC tags response", err)
276
294
return nil, err
277
295
}
278
296
+7
-2
appview/repo/feed.go
+7
-2
appview/repo/feed.go
···
9
9
"time"
10
10
11
11
"tangled.sh/tangled.sh/core/appview/db"
12
+
"tangled.sh/tangled.sh/core/appview/pagination"
12
13
"tangled.sh/tangled.sh/core/appview/reporesolver"
13
14
14
15
"github.com/bluesky-social/indigo/atproto/syntax"
···
23
24
return nil, err
24
25
}
25
26
26
-
issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
27
+
issues, err := db.GetIssuesPaginated(
28
+
rp.db,
29
+
pagination.Page{Limit: feedLimitPerType},
30
+
db.FilterEq("repo_at", f.RepoAt()),
31
+
)
27
32
if err != nil {
28
33
return nil, err
29
34
}
···
104
109
}
105
110
106
111
func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
107
-
owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid)
112
+
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
108
113
if err != nil {
109
114
return nil, err
110
115
}
+207
-22
appview/repo/index.go
+207
-22
appview/repo/index.go
···
1
1
package repo
2
2
3
3
import (
4
+
"errors"
5
+
"fmt"
4
6
"log"
5
7
"net/http"
6
8
"slices"
7
9
"sort"
8
10
"strings"
11
+
"sync"
12
+
"time"
9
13
14
+
"context"
15
+
"encoding/json"
16
+
17
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
18
+
"github.com/go-git/go-git/v5/plumbing"
19
+
"tangled.sh/tangled.sh/core/api/tangled"
10
20
"tangled.sh/tangled.sh/core/appview/commitverify"
11
21
"tangled.sh/tangled.sh/core/appview/db"
12
22
"tangled.sh/tangled.sh/core/appview/pages"
23
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
13
24
"tangled.sh/tangled.sh/core/appview/reporesolver"
14
-
"tangled.sh/tangled.sh/core/knotclient"
25
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
15
26
"tangled.sh/tangled.sh/core/types"
16
27
17
28
"github.com/go-chi/chi/v5"
···
27
38
return
28
39
}
29
40
30
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
31
-
if err != nil {
32
-
log.Printf("failed to create unsigned client for %s", f.Knot)
33
-
rp.pages.Error503(w)
34
-
return
41
+
scheme := "http"
42
+
if !rp.config.Core.Dev {
43
+
scheme = "https"
44
+
}
45
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
46
+
xrpcc := &indigoxrpc.Client{
47
+
Host: host,
35
48
}
36
49
37
-
result, err := us.Index(f.OwnerDid(), f.Name, ref)
38
-
if err != nil {
39
-
rp.pages.Error503(w)
40
-
log.Println("failed to reach knotserver", err)
41
-
return
50
+
user := rp.oauth.GetUser(r)
51
+
repoInfo := f.RepoInfo(user)
52
+
53
+
// Build index response from multiple XRPC calls
54
+
result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
55
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
56
+
if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) {
57
+
log.Println("failed to call XRPC repo.index", err)
58
+
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
59
+
LoggedInUser: user,
60
+
NeedsKnotUpgrade: true,
61
+
RepoInfo: repoInfo,
62
+
})
63
+
return
64
+
} else {
65
+
rp.pages.Error503(w)
66
+
log.Println("failed to build index response", err)
67
+
return
68
+
}
42
69
}
43
70
44
71
tagMap := make(map[string][]string)
···
98
125
log.Println(err)
99
126
}
100
127
101
-
user := rp.oauth.GetUser(r)
102
-
repoInfo := f.RepoInfo(user)
103
-
104
128
// TODO: a bit dirty
105
-
languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "")
129
+
languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "")
106
130
if err != nil {
107
131
log.Printf("failed to compute language percentages: %s", err)
108
132
// non-fatal
···
135
159
}
136
160
137
161
func (rp *Repo) getLanguageInfo(
162
+
ctx context.Context,
138
163
f *reporesolver.ResolvedRepo,
139
-
us *knotclient.UnsignedClient,
164
+
xrpcc *indigoxrpc.Client,
140
165
currentRef string,
141
166
isDefaultRef bool,
142
167
) ([]types.RepoLanguageDetails, error) {
···
148
173
)
149
174
150
175
if err != nil || langs == nil {
151
-
// non-fatal, fetch langs from ks
152
-
ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef)
176
+
// non-fatal, fetch langs from ks via XRPC
177
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
178
+
ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo)
153
179
if err != nil {
180
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
181
+
log.Println("failed to call XRPC repo.languages", xrpcerr)
182
+
return nil, xrpcerr
183
+
}
154
184
return nil, err
155
185
}
156
-
if ls == nil {
186
+
187
+
if ls == nil || ls.Languages == nil {
157
188
return nil, nil
158
189
}
159
190
160
-
for l, s := range ls.Languages {
191
+
for _, lang := range ls.Languages {
161
192
langs = append(langs, db.RepoLanguage{
162
193
RepoAt: f.RepoAt(),
163
194
Ref: currentRef,
164
195
IsDefaultRef: isDefaultRef,
165
-
Language: l,
166
-
Bytes: s,
196
+
Language: lang.Name,
197
+
Bytes: lang.Size,
167
198
})
168
199
}
169
200
···
206
237
207
238
return languageStats, nil
208
239
}
240
+
241
+
// buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel
242
+
func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) {
243
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
244
+
245
+
// first get branches to determine the ref if not specified
246
+
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
247
+
if err != nil {
248
+
return nil, err
249
+
}
250
+
251
+
var branchesResp types.RepoBranchesResponse
252
+
if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil {
253
+
return nil, err
254
+
}
255
+
256
+
// if no ref specified, use default branch or first available
257
+
if ref == "" && len(branchesResp.Branches) > 0 {
258
+
for _, branch := range branchesResp.Branches {
259
+
if branch.IsDefault {
260
+
ref = branch.Name
261
+
break
262
+
}
263
+
}
264
+
if ref == "" {
265
+
ref = branchesResp.Branches[0].Name
266
+
}
267
+
}
268
+
269
+
// check if repo is empty
270
+
if len(branchesResp.Branches) == 0 {
271
+
return &types.RepoIndexResponse{
272
+
IsEmpty: true,
273
+
Branches: branchesResp.Branches,
274
+
}, nil
275
+
}
276
+
277
+
// now run the remaining queries in parallel
278
+
var wg sync.WaitGroup
279
+
var errs error
280
+
281
+
var (
282
+
tagsResp types.RepoTagsResponse
283
+
treeResp *tangled.RepoTree_Output
284
+
logResp types.RepoLogResponse
285
+
readmeContent string
286
+
readmeFileName string
287
+
)
288
+
289
+
// tags
290
+
wg.Add(1)
291
+
go func() {
292
+
defer wg.Done()
293
+
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
294
+
if err != nil {
295
+
errs = errors.Join(errs, err)
296
+
return
297
+
}
298
+
299
+
if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil {
300
+
errs = errors.Join(errs, err)
301
+
}
302
+
}()
303
+
304
+
// tree/files
305
+
wg.Add(1)
306
+
go func() {
307
+
defer wg.Done()
308
+
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo)
309
+
if err != nil {
310
+
errs = errors.Join(errs, err)
311
+
return
312
+
}
313
+
treeResp = resp
314
+
}()
315
+
316
+
// commits
317
+
wg.Add(1)
318
+
go func() {
319
+
defer wg.Done()
320
+
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo)
321
+
if err != nil {
322
+
errs = errors.Join(errs, err)
323
+
return
324
+
}
325
+
326
+
if err := json.Unmarshal(logBytes, &logResp); err != nil {
327
+
errs = errors.Join(errs, err)
328
+
}
329
+
}()
330
+
331
+
// readme content
332
+
wg.Add(1)
333
+
go func() {
334
+
defer wg.Done()
335
+
for _, filename := range markup.ReadmeFilenames {
336
+
blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo)
337
+
if err != nil {
338
+
continue
339
+
}
340
+
341
+
if blobResp == nil {
342
+
continue
343
+
}
344
+
345
+
readmeContent = blobResp.Content
346
+
readmeFileName = filename
347
+
break
348
+
}
349
+
}()
350
+
351
+
wg.Wait()
352
+
353
+
if errs != nil {
354
+
return nil, errs
355
+
}
356
+
357
+
var files []types.NiceTree
358
+
if treeResp != nil && treeResp.Files != nil {
359
+
for _, file := range treeResp.Files {
360
+
niceFile := types.NiceTree{
361
+
IsFile: file.Is_file,
362
+
IsSubtree: file.Is_subtree,
363
+
Name: file.Name,
364
+
Mode: file.Mode,
365
+
Size: file.Size,
366
+
}
367
+
if file.Last_commit != nil {
368
+
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
369
+
niceFile.LastCommit = &types.LastCommitInfo{
370
+
Hash: plumbing.NewHash(file.Last_commit.Hash),
371
+
Message: file.Last_commit.Message,
372
+
When: when,
373
+
}
374
+
}
375
+
files = append(files, niceFile)
376
+
}
377
+
}
378
+
379
+
result := &types.RepoIndexResponse{
380
+
IsEmpty: false,
381
+
Ref: ref,
382
+
Readme: readmeContent,
383
+
ReadmeFileName: readmeFileName,
384
+
Commits: logResp.Commits,
385
+
Description: logResp.Description,
386
+
Files: files,
387
+
Branches: branchesResp.Branches,
388
+
Tags: tagsResp.Tags,
389
+
TotalCommits: logResp.Total,
390
+
}
391
+
392
+
return result, nil
393
+
}
+374
-144
appview/repo/repo.go
+374
-144
appview/repo/repo.go
···
19
19
20
20
comatproto "github.com/bluesky-social/indigo/api/atproto"
21
21
lexutil "github.com/bluesky-social/indigo/lex/util"
22
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
22
23
"tangled.sh/tangled.sh/core/api/tangled"
23
24
"tangled.sh/tangled.sh/core/appview/commitverify"
24
25
"tangled.sh/tangled.sh/core/appview/config"
···
31
32
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
32
33
"tangled.sh/tangled.sh/core/eventconsumer"
33
34
"tangled.sh/tangled.sh/core/idresolver"
34
-
"tangled.sh/tangled.sh/core/knotclient"
35
35
"tangled.sh/tangled.sh/core/patchutil"
36
36
"tangled.sh/tangled.sh/core/rbac"
37
37
"tangled.sh/tangled.sh/core/tid"
···
92
92
return
93
93
}
94
94
95
-
var uri string
96
-
if rp.config.Core.Dev {
97
-
uri = "http"
98
-
} else {
99
-
uri = "https"
95
+
scheme := "http"
96
+
if !rp.config.Core.Dev {
97
+
scheme = "https"
98
+
}
99
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
100
+
xrpcc := &indigoxrpc.Client{
101
+
Host: host,
102
+
}
103
+
104
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
105
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", refParam, repo)
106
+
if err != nil {
107
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
108
+
log.Println("failed to call XRPC repo.archive", xrpcerr)
109
+
rp.pages.Error503(w)
110
+
return
111
+
}
112
+
rp.pages.Error404(w)
113
+
return
100
114
}
101
-
url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam))
115
+
116
+
// Set headers for file download
117
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, refParam)
118
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
119
+
w.Header().Set("Content-Type", "application/gzip")
120
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
102
121
103
-
http.Redirect(w, r, url, http.StatusFound)
122
+
// Write the archive data directly
123
+
w.Write(archiveBytes)
104
124
}
105
125
106
126
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
···
120
140
121
141
ref := chi.URLParam(r, "ref")
122
142
123
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
143
+
scheme := "http"
144
+
if !rp.config.Core.Dev {
145
+
scheme = "https"
146
+
}
147
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
148
+
xrpcc := &indigoxrpc.Client{
149
+
Host: host,
150
+
}
151
+
152
+
limit := int64(60)
153
+
cursor := ""
154
+
if page > 1 {
155
+
// Convert page number to cursor (offset)
156
+
offset := (page - 1) * int(limit)
157
+
cursor = strconv.Itoa(offset)
158
+
}
159
+
160
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
161
+
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
124
162
if err != nil {
125
-
log.Println("failed to create unsigned client", err)
163
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
164
+
log.Println("failed to call XRPC repo.log", xrpcerr)
165
+
rp.pages.Error503(w)
166
+
return
167
+
}
168
+
rp.pages.Error404(w)
126
169
return
127
170
}
128
171
129
-
repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page)
130
-
if err != nil {
172
+
var xrpcResp types.RepoLogResponse
173
+
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
174
+
log.Println("failed to decode XRPC response", err)
131
175
rp.pages.Error503(w)
132
-
log.Println("failed to reach knotserver", err)
133
176
return
134
177
}
135
178
136
-
tagResult, err := us.Tags(f.OwnerDid(), f.Name)
179
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
137
180
if err != nil {
138
-
rp.pages.Error503(w)
139
-
log.Println("failed to reach knotserver", err)
140
-
return
181
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
182
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
183
+
rp.pages.Error503(w)
184
+
return
185
+
}
141
186
}
142
187
143
188
tagMap := make(map[string][]string)
144
-
for _, tag := range tagResult.Tags {
145
-
hash := tag.Hash
146
-
if tag.Tag != nil {
147
-
hash = tag.Tag.Target.String()
189
+
if tagBytes != nil {
190
+
var tagResp types.RepoTagsResponse
191
+
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
192
+
for _, tag := range tagResp.Tags {
193
+
tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name)
194
+
}
148
195
}
149
-
tagMap[hash] = append(tagMap[hash], tag.Name)
150
196
}
151
197
152
-
branchResult, err := us.Branches(f.OwnerDid(), f.Name)
198
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
153
199
if err != nil {
154
-
rp.pages.Error503(w)
155
-
log.Println("failed to reach knotserver", err)
156
-
return
200
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
201
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
202
+
rp.pages.Error503(w)
203
+
return
204
+
}
157
205
}
158
206
159
-
for _, branch := range branchResult.Branches {
160
-
hash := branch.Hash
161
-
tagMap[hash] = append(tagMap[hash], branch.Name)
207
+
if branchBytes != nil {
208
+
var branchResp types.RepoBranchesResponse
209
+
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
210
+
for _, branch := range branchResp.Branches {
211
+
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
212
+
}
213
+
}
162
214
}
163
215
164
216
user := rp.oauth.GetUser(r)
165
217
166
-
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true)
218
+
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
167
219
if err != nil {
168
220
log.Println("failed to fetch email to did mapping", err)
169
221
}
170
222
171
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits)
223
+
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
172
224
if err != nil {
173
225
log.Println(err)
174
226
}
···
176
228
repoInfo := f.RepoInfo(user)
177
229
178
230
var shas []string
179
-
for _, c := range repolog.Commits {
231
+
for _, c := range xrpcResp.Commits {
180
232
shas = append(shas, c.Hash.String())
181
233
}
182
234
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
···
189
241
LoggedInUser: user,
190
242
TagMap: tagMap,
191
243
RepoInfo: repoInfo,
192
-
RepoLogResponse: *repolog,
244
+
RepoLogResponse: xrpcResp,
193
245
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
194
246
VerifiedCommits: vc,
195
247
Pipelines: pipelines,
···
301
353
return
302
354
}
303
355
ref := chi.URLParam(r, "ref")
304
-
protocol := "http"
305
-
if !rp.config.Core.Dev {
306
-
protocol = "https"
307
-
}
308
356
309
357
var diffOpts types.DiffOpts
310
358
if d := r.URL.Query().Get("diff"); d == "split" {
···
316
364
return
317
365
}
318
366
319
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref))
320
-
if err != nil {
321
-
rp.pages.Error503(w)
322
-
log.Println("failed to reach knotserver", err)
323
-
return
367
+
scheme := "http"
368
+
if !rp.config.Core.Dev {
369
+
scheme = "https"
370
+
}
371
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
372
+
xrpcc := &indigoxrpc.Client{
373
+
Host: host,
324
374
}
325
375
326
-
body, err := io.ReadAll(resp.Body)
376
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
377
+
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
327
378
if err != nil {
328
-
log.Printf("Error reading response body: %v", err)
379
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
380
+
log.Println("failed to call XRPC repo.diff", xrpcerr)
381
+
rp.pages.Error503(w)
382
+
return
383
+
}
384
+
rp.pages.Error404(w)
329
385
return
330
386
}
331
387
332
388
var result types.RepoCommitResponse
333
-
err = json.Unmarshal(body, &result)
334
-
if err != nil {
335
-
log.Println("failed to parse response:", err)
389
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
390
+
log.Println("failed to decode XRPC response", err)
391
+
rp.pages.Error503(w)
336
392
return
337
393
}
338
394
···
378
434
379
435
ref := chi.URLParam(r, "ref")
380
436
treePath := chi.URLParam(r, "*")
381
-
protocol := "http"
382
-
if !rp.config.Core.Dev {
383
-
protocol = "https"
384
-
}
385
437
386
438
// if the tree path has a trailing slash, let's strip it
387
439
// so we don't 404
388
440
treePath = strings.TrimSuffix(treePath, "/")
389
441
390
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath))
442
+
scheme := "http"
443
+
if !rp.config.Core.Dev {
444
+
scheme = "https"
445
+
}
446
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
447
+
xrpcc := &indigoxrpc.Client{
448
+
Host: host,
449
+
}
450
+
451
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
452
+
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
391
453
if err != nil {
392
-
rp.pages.Error503(w)
393
-
log.Println("failed to reach knotserver", err)
454
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
455
+
log.Println("failed to call XRPC repo.tree", xrpcerr)
456
+
rp.pages.Error503(w)
457
+
return
458
+
}
459
+
rp.pages.Error404(w)
394
460
return
395
461
}
396
462
397
-
// uhhh so knotserver returns a 500 if the entry isn't found in
398
-
// the requested tree path, so let's stick to not-OK here.
399
-
// we can fix this once we build out the xrpc apis for these operations.
400
-
if resp.StatusCode != http.StatusOK {
401
-
rp.pages.Error404(w)
402
-
return
463
+
// Convert XRPC response to internal types.RepoTreeResponse
464
+
files := make([]types.NiceTree, len(xrpcResp.Files))
465
+
for i, xrpcFile := range xrpcResp.Files {
466
+
file := types.NiceTree{
467
+
Name: xrpcFile.Name,
468
+
Mode: xrpcFile.Mode,
469
+
Size: int64(xrpcFile.Size),
470
+
IsFile: xrpcFile.Is_file,
471
+
IsSubtree: xrpcFile.Is_subtree,
472
+
}
473
+
474
+
// Convert last commit info if present
475
+
if xrpcFile.Last_commit != nil {
476
+
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
477
+
file.LastCommit = &types.LastCommitInfo{
478
+
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
479
+
Message: xrpcFile.Last_commit.Message,
480
+
When: commitWhen,
481
+
}
482
+
}
483
+
484
+
files[i] = file
403
485
}
404
486
405
-
body, err := io.ReadAll(resp.Body)
406
-
if err != nil {
407
-
log.Printf("Error reading response body: %v", err)
408
-
return
487
+
result := types.RepoTreeResponse{
488
+
Ref: xrpcResp.Ref,
489
+
Files: files,
409
490
}
410
491
411
-
var result types.RepoTreeResponse
412
-
err = json.Unmarshal(body, &result)
413
-
if err != nil {
414
-
log.Println("failed to parse response:", err)
415
-
return
492
+
if xrpcResp.Parent != nil {
493
+
result.Parent = *xrpcResp.Parent
494
+
}
495
+
if xrpcResp.Dotdot != nil {
496
+
result.DotDot = *xrpcResp.Dotdot
416
497
}
417
498
418
499
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
···
451
532
return
452
533
}
453
534
454
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
535
+
scheme := "http"
536
+
if !rp.config.Core.Dev {
537
+
scheme = "https"
538
+
}
539
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
540
+
xrpcc := &indigoxrpc.Client{
541
+
Host: host,
542
+
}
543
+
544
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
545
+
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
455
546
if err != nil {
456
-
log.Println("failed to create unsigned client", err)
547
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
548
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
549
+
rp.pages.Error503(w)
550
+
return
551
+
}
552
+
rp.pages.Error404(w)
457
553
return
458
554
}
459
555
460
-
result, err := us.Tags(f.OwnerDid(), f.Name)
461
-
if err != nil {
556
+
var result types.RepoTagsResponse
557
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
558
+
log.Println("failed to decode XRPC response", err)
462
559
rp.pages.Error503(w)
463
-
log.Println("failed to reach knotserver", err)
464
560
return
465
561
}
466
562
···
496
592
rp.pages.RepoTags(w, pages.RepoTagsParams{
497
593
LoggedInUser: user,
498
594
RepoInfo: f.RepoInfo(user),
499
-
RepoTagsResponse: *result,
595
+
RepoTagsResponse: result,
500
596
ArtifactMap: artifactMap,
501
597
DanglingArtifacts: danglingArtifacts,
502
598
})
···
509
605
return
510
606
}
511
607
512
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
608
+
scheme := "http"
609
+
if !rp.config.Core.Dev {
610
+
scheme = "https"
611
+
}
612
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
613
+
xrpcc := &indigoxrpc.Client{
614
+
Host: host,
615
+
}
616
+
617
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
618
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
513
619
if err != nil {
514
-
log.Println("failed to create unsigned client", err)
620
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
621
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
622
+
rp.pages.Error503(w)
623
+
return
624
+
}
625
+
rp.pages.Error404(w)
515
626
return
516
627
}
517
628
518
-
result, err := us.Branches(f.OwnerDid(), f.Name)
519
-
if err != nil {
629
+
var result types.RepoBranchesResponse
630
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
631
+
log.Println("failed to decode XRPC response", err)
520
632
rp.pages.Error503(w)
521
-
log.Println("failed to reach knotserver", err)
522
633
return
523
634
}
524
635
···
528
639
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
529
640
LoggedInUser: user,
530
641
RepoInfo: f.RepoInfo(user),
531
-
RepoBranchesResponse: *result,
642
+
RepoBranchesResponse: result,
532
643
})
533
644
}
534
645
···
541
652
542
653
ref := chi.URLParam(r, "ref")
543
654
filePath := chi.URLParam(r, "*")
544
-
protocol := "http"
655
+
656
+
scheme := "http"
545
657
if !rp.config.Core.Dev {
546
-
protocol = "https"
658
+
scheme = "https"
547
659
}
548
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath))
549
-
if err != nil {
550
-
rp.pages.Error503(w)
551
-
log.Println("failed to reach knotserver", err)
552
-
return
660
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
661
+
xrpcc := &indigoxrpc.Client{
662
+
Host: host,
553
663
}
554
664
555
-
if resp.StatusCode == http.StatusNotFound {
665
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
666
+
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
667
+
if err != nil {
668
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
669
+
log.Println("failed to call XRPC repo.blob", xrpcerr)
670
+
rp.pages.Error503(w)
671
+
return
672
+
}
556
673
rp.pages.Error404(w)
557
674
return
558
675
}
559
676
560
-
body, err := io.ReadAll(resp.Body)
561
-
if err != nil {
562
-
log.Printf("Error reading response body: %v", err)
563
-
return
564
-
}
565
-
566
-
var result types.RepoBlobResponse
567
-
err = json.Unmarshal(body, &result)
568
-
if err != nil {
569
-
log.Println("failed to parse response:", err)
570
-
return
571
-
}
677
+
// Use XRPC response directly instead of converting to internal types
572
678
573
679
var breadcrumbs [][]string
574
680
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
···
581
687
showRendered := false
582
688
renderToggle := false
583
689
584
-
if markup.GetFormat(result.Path) == markup.FormatMarkdown {
690
+
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
585
691
renderToggle = true
586
692
showRendered = r.URL.Query().Get("code") != "true"
587
693
}
···
591
697
var isVideo bool
592
698
var contentSrc string
593
699
594
-
if result.IsBinary {
595
-
ext := strings.ToLower(filepath.Ext(result.Path))
700
+
if resp.IsBinary != nil && *resp.IsBinary {
701
+
ext := strings.ToLower(filepath.Ext(resp.Path))
596
702
switch ext {
597
703
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
598
704
isImage = true
···
602
708
unsupported = true
603
709
}
604
710
605
-
// fetch the actual binary content like in RepoBlobRaw
711
+
// fetch the raw binary content using sh.tangled.repo.blob xrpc
712
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
713
+
blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true",
714
+
scheme, f.Knot, url.QueryEscape(repoName), url.QueryEscape(ref), url.QueryEscape(filePath))
606
715
607
-
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath)
608
716
contentSrc = blobURL
609
717
if !rp.config.Core.Dev {
610
718
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
611
719
}
612
720
}
613
721
722
+
lines := 0
723
+
if resp.IsBinary == nil || !*resp.IsBinary {
724
+
lines = strings.Count(resp.Content, "\n") + 1
725
+
}
726
+
727
+
var sizeHint uint64
728
+
if resp.Size != nil {
729
+
sizeHint = uint64(*resp.Size)
730
+
} else {
731
+
sizeHint = uint64(len(resp.Content))
732
+
}
733
+
614
734
user := rp.oauth.GetUser(r)
735
+
736
+
// Determine if content is binary (dereference pointer)
737
+
isBinary := false
738
+
if resp.IsBinary != nil {
739
+
isBinary = *resp.IsBinary
740
+
}
741
+
615
742
rp.pages.RepoBlob(w, pages.RepoBlobParams{
616
-
LoggedInUser: user,
617
-
RepoInfo: f.RepoInfo(user),
618
-
RepoBlobResponse: result,
619
-
BreadCrumbs: breadcrumbs,
620
-
ShowRendered: showRendered,
621
-
RenderToggle: renderToggle,
622
-
Unsupported: unsupported,
623
-
IsImage: isImage,
624
-
IsVideo: isVideo,
625
-
ContentSrc: contentSrc,
743
+
LoggedInUser: user,
744
+
RepoInfo: f.RepoInfo(user),
745
+
BreadCrumbs: breadcrumbs,
746
+
ShowRendered: showRendered,
747
+
RenderToggle: renderToggle,
748
+
Unsupported: unsupported,
749
+
IsImage: isImage,
750
+
IsVideo: isVideo,
751
+
ContentSrc: contentSrc,
752
+
RepoBlob_Output: resp,
753
+
Contents: resp.Content,
754
+
Lines: lines,
755
+
SizeHint: sizeHint,
756
+
IsBinary: isBinary,
626
757
})
627
758
}
628
759
···
637
768
ref := chi.URLParam(r, "ref")
638
769
filePath := chi.URLParam(r, "*")
639
770
640
-
protocol := "http"
771
+
scheme := "http"
641
772
if !rp.config.Core.Dev {
642
-
protocol = "https"
773
+
scheme = "https"
643
774
}
644
775
645
-
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)
776
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
777
+
blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true",
778
+
scheme, f.Knot, url.QueryEscape(repo), url.QueryEscape(ref), url.QueryEscape(filePath))
646
779
647
780
req, err := http.NewRequest("GET", blobURL, nil)
648
781
if err != nil {
···
685
818
return
686
819
}
687
820
688
-
if strings.Contains(contentType, "text/plain") {
821
+
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
822
+
// serve all textual content as text/plain
689
823
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
690
824
w.Write(body)
691
825
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
826
+
// serve images and videos with their original content type
692
827
w.Header().Set("Content-Type", contentType)
693
828
w.Write(body)
694
829
} else {
···
698
833
}
699
834
}
700
835
836
+
// isTextualMimeType returns true if the MIME type represents textual content
837
+
// that should be served as text/plain
838
+
func isTextualMimeType(mimeType string) bool {
839
+
textualTypes := []string{
840
+
"application/json",
841
+
"application/xml",
842
+
"application/yaml",
843
+
"application/x-yaml",
844
+
"application/toml",
845
+
"application/javascript",
846
+
"application/ecmascript",
847
+
"message/",
848
+
}
849
+
850
+
return slices.Contains(textualTypes, mimeType)
851
+
}
852
+
701
853
// modify the spindle configured for this repo
702
854
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
703
855
user := rp.oauth.GetUser(r)
···
1201
1353
f, err := rp.repoResolver.Resolve(r)
1202
1354
user := rp.oauth.GetUser(r)
1203
1355
1204
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1356
+
scheme := "http"
1357
+
if !rp.config.Core.Dev {
1358
+
scheme = "https"
1359
+
}
1360
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1361
+
xrpcc := &indigoxrpc.Client{
1362
+
Host: host,
1363
+
}
1364
+
1365
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1366
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1205
1367
if err != nil {
1206
-
log.Println("failed to create unsigned client", err)
1368
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1369
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
1370
+
rp.pages.Error503(w)
1371
+
return
1372
+
}
1373
+
rp.pages.Error503(w)
1207
1374
return
1208
1375
}
1209
1376
1210
-
result, err := us.Branches(f.OwnerDid(), f.Name)
1211
-
if err != nil {
1377
+
var result types.RepoBranchesResponse
1378
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1379
+
log.Println("failed to decode XRPC response", err)
1212
1380
rp.pages.Error503(w)
1213
-
log.Println("failed to reach knotserver", err)
1214
1381
return
1215
1382
}
1216
1383
···
1581
1748
return
1582
1749
}
1583
1750
1584
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1751
+
scheme := "http"
1752
+
if !rp.config.Core.Dev {
1753
+
scheme = "https"
1754
+
}
1755
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1756
+
xrpcc := &indigoxrpc.Client{
1757
+
Host: host,
1758
+
}
1759
+
1760
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1761
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1585
1762
if err != nil {
1586
-
log.Printf("failed to create unsigned client for %s", f.Knot)
1587
-
rp.pages.Error503(w)
1763
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1764
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
1765
+
rp.pages.Error503(w)
1766
+
return
1767
+
}
1768
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1588
1769
return
1589
1770
}
1590
1771
1591
-
result, err := us.Branches(f.OwnerDid(), f.Name)
1592
-
if err != nil {
1772
+
var branchResult types.RepoBranchesResponse
1773
+
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
1774
+
log.Println("failed to decode XRPC branches response", err)
1593
1775
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1594
-
log.Println("failed to reach knotserver", err)
1595
1776
return
1596
1777
}
1597
-
branches := result.Branches
1778
+
branches := branchResult.Branches
1598
1779
1599
1780
sortBranches(branches)
1600
1781
···
1618
1799
head = queryHead
1619
1800
}
1620
1801
1621
-
tags, err := us.Tags(f.OwnerDid(), f.Name)
1802
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
1622
1803
if err != nil {
1804
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1805
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
1806
+
rp.pages.Error503(w)
1807
+
return
1808
+
}
1623
1809
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1624
-
log.Println("failed to reach knotserver", err)
1810
+
return
1811
+
}
1812
+
1813
+
var tags types.RepoTagsResponse
1814
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
1815
+
log.Println("failed to decode XRPC tags response", err)
1816
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1625
1817
return
1626
1818
}
1627
1819
···
1673
1865
return
1674
1866
}
1675
1867
1676
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1868
+
scheme := "http"
1869
+
if !rp.config.Core.Dev {
1870
+
scheme = "https"
1871
+
}
1872
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1873
+
xrpcc := &indigoxrpc.Client{
1874
+
Host: host,
1875
+
}
1876
+
1877
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1878
+
1879
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1677
1880
if err != nil {
1678
-
log.Printf("failed to create unsigned client for %s", f.Knot)
1679
-
rp.pages.Error503(w)
1881
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1882
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
1883
+
rp.pages.Error503(w)
1884
+
return
1885
+
}
1886
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1680
1887
return
1681
1888
}
1682
1889
1683
-
branches, err := us.Branches(f.OwnerDid(), f.Name)
1684
-
if err != nil {
1890
+
var branches types.RepoBranchesResponse
1891
+
if err := json.Unmarshal(branchBytes, &branches); err != nil {
1892
+
log.Println("failed to decode XRPC branches response", err)
1685
1893
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1686
-
log.Println("failed to reach knotserver", err)
1687
1894
return
1688
1895
}
1689
1896
1690
-
tags, err := us.Tags(f.OwnerDid(), f.Name)
1897
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
1691
1898
if err != nil {
1899
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1900
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
1901
+
rp.pages.Error503(w)
1902
+
return
1903
+
}
1692
1904
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1693
-
log.Println("failed to reach knotserver", err)
1694
1905
return
1695
1906
}
1696
1907
1697
-
formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head)
1908
+
var tags types.RepoTagsResponse
1909
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
1910
+
log.Println("failed to decode XRPC tags response", err)
1911
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1912
+
return
1913
+
}
1914
+
1915
+
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
1698
1916
if err != nil {
1917
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1918
+
log.Println("failed to call XRPC repo.compare", xrpcerr)
1919
+
rp.pages.Error503(w)
1920
+
return
1921
+
}
1699
1922
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1700
-
log.Println("failed to compare", err)
1701
1923
return
1702
1924
}
1925
+
1926
+
var formatPatch types.RepoFormatPatchResponse
1927
+
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
1928
+
log.Println("failed to decode XRPC compare response", err)
1929
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1930
+
return
1931
+
}
1932
+
1703
1933
diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
1704
1934
1705
1935
repoinfo := f.RepoInfo(user)
+11
-27
appview/serververify/verify.go
+11
-27
appview/serververify/verify.go
···
4
4
"context"
5
5
"errors"
6
6
"fmt"
7
-
"io"
8
-
"net/http"
9
-
"strings"
10
-
"time"
11
7
8
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
9
+
"tangled.sh/tangled.sh/core/api/tangled"
12
10
"tangled.sh/tangled.sh/core/appview/db"
11
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
13
12
"tangled.sh/tangled.sh/core/rbac"
14
13
)
15
14
···
24
23
scheme = "http"
25
24
}
26
25
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")
26
+
host := fmt.Sprintf("%s://%s", scheme, domain)
27
+
xrpcc := &indigoxrpc.Client{
28
+
Host: host,
40
29
}
41
30
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)
31
+
res, err := tangled.Owner(ctx, xrpcc)
32
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
33
+
return "", xrpcerr
45
34
}
46
35
47
-
did := strings.TrimSpace(string(body))
48
-
if did == "" {
49
-
return "", fmt.Errorf("empty DID in /owner response")
50
-
}
51
-
52
-
return did, nil
36
+
return res.Owner, nil
53
37
}
54
38
55
39
type OwnerMismatch struct {
···
65
49
func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error {
66
50
observedOwner, err := fetchOwner(ctx, domain, dev)
67
51
if err != nil {
68
-
return fmt.Errorf("%w: %w", FetchError, err)
52
+
return err
69
53
}
70
54
71
55
if observedOwner != expectedOwner {
+4
-3
appview/spindles/spindles.go
+4
-3
appview/spindles/spindles.go
···
16
16
"tangled.sh/tangled.sh/core/appview/oauth"
17
17
"tangled.sh/tangled.sh/core/appview/pages"
18
18
"tangled.sh/tangled.sh/core/appview/serververify"
19
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
19
20
"tangled.sh/tangled.sh/core/idresolver"
20
21
"tangled.sh/tangled.sh/core/rbac"
21
22
"tangled.sh/tangled.sh/core/tid"
···
404
405
if err != nil {
405
406
l.Error("verification failed", "err", err)
406
407
407
-
if errors.Is(err, serververify.FetchError) {
408
-
s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.")
408
+
if errors.Is(err, xrpcclient.ErrXrpcUnsupported) {
409
+
s.Pages.Notice(w, noticeId, "Failed to verify spindle, XRPC queries are unsupported on this spindle, consider upgrading!")
409
410
return
410
411
}
411
412
···
442
443
}
443
444
444
445
w.Header().Set("HX-Reswap", "outerHTML")
445
-
s.Pages.SpindleListing(w, pages.SpindleListingParams{verifiedSpindle[0]})
446
+
s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]})
446
447
}
447
448
448
449
func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) {
+197
-137
appview/state/profile.go
+197
-137
appview/state/profile.go
···
17
17
"github.com/gorilla/feeds"
18
18
"tangled.sh/tangled.sh/core/api/tangled"
19
19
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/oauth"
21
20
"tangled.sh/tangled.sh/core/appview/pages"
22
21
)
23
22
24
23
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
25
24
tabVal := r.URL.Query().Get("tab")
26
25
switch tabVal {
27
-
case "":
28
-
s.profileHomePage(w, r)
29
26
case "repos":
30
27
s.reposPage(w, r)
31
28
case "followers":
32
29
s.followersPage(w, r)
33
30
case "following":
34
31
s.followingPage(w, r)
32
+
case "starred":
33
+
s.starredPage(w, r)
34
+
case "strings":
35
+
s.stringsPage(w, r)
36
+
default:
37
+
s.profileOverview(w, r)
35
38
}
36
39
}
37
40
38
-
type ProfilePageParams struct {
39
-
Id identity.Identity
40
-
LoggedInUser *oauth.User
41
-
Card pages.ProfileCard
42
-
}
43
-
44
-
func (s *State) profilePage(w http.ResponseWriter, r *http.Request) *ProfilePageParams {
41
+
func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) {
45
42
didOrHandle := chi.URLParam(r, "user")
46
43
if didOrHandle == "" {
47
-
http.Error(w, "bad request", http.StatusBadRequest)
48
-
return nil
44
+
return nil, fmt.Errorf("empty DID or handle")
49
45
}
50
46
51
47
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
52
48
if !ok {
53
-
log.Printf("malformed middleware")
54
-
w.WriteHeader(http.StatusInternalServerError)
55
-
return nil
49
+
return nil, fmt.Errorf("failed to resolve ID")
56
50
}
57
51
did := ident.DID.String()
58
52
59
53
profile, err := db.GetProfile(s.db, did)
60
54
if err != nil {
61
-
log.Printf("getting profile data for %s: %s", did, err)
62
-
s.pages.Error500(w)
63
-
return nil
55
+
return nil, fmt.Errorf("failed to get profile: %w", err)
56
+
}
57
+
58
+
repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did))
59
+
if err != nil {
60
+
return nil, fmt.Errorf("failed to get repo count: %w", err)
61
+
}
62
+
63
+
stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did))
64
+
if err != nil {
65
+
return nil, fmt.Errorf("failed to get string count: %w", err)
66
+
}
67
+
68
+
starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did))
69
+
if err != nil {
70
+
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
64
71
}
65
72
66
73
followStats, err := db.GetFollowerFollowingCount(s.db, did)
67
74
if err != nil {
68
-
log.Printf("getting follow stats for %s: %s", did, err)
75
+
return nil, fmt.Errorf("failed to get follower stats: %w", err)
69
76
}
70
77
71
78
loggedInUser := s.oauth.GetUser(r)
···
74
81
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
75
82
}
76
83
77
-
return &ProfilePageParams{
78
-
Id: ident,
79
-
LoggedInUser: loggedInUser,
80
-
Card: pages.ProfileCard{
81
-
UserDid: did,
82
-
UserHandle: ident.Handle.String(),
83
-
Profile: profile,
84
-
FollowStatus: followStatus,
84
+
now := time.Now()
85
+
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
86
+
punchcard, err := db.MakePunchcard(
87
+
s.db,
88
+
db.FilterEq("did", did),
89
+
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
90
+
db.FilterLte("date", now.Format(time.DateOnly)),
91
+
)
92
+
if err != nil {
93
+
return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err)
94
+
}
95
+
96
+
return &pages.ProfileCard{
97
+
UserDid: did,
98
+
UserHandle: ident.Handle.String(),
99
+
Profile: profile,
100
+
FollowStatus: followStatus,
101
+
Stats: pages.ProfileStats{
102
+
RepoCount: repoCount,
103
+
StringCount: stringCount,
104
+
StarredCount: starredCount,
85
105
FollowersCount: followStats.Followers,
86
106
FollowingCount: followStats.Following,
87
107
},
88
-
}
108
+
Punchcard: punchcard,
109
+
}, nil
89
110
}
90
111
91
-
func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) {
92
-
pageWithProfile := s.profilePage(w, r)
93
-
if pageWithProfile == nil {
112
+
func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) {
113
+
l := s.logger.With("handler", "profileHomePage")
114
+
115
+
profile, err := s.profile(r)
116
+
if err != nil {
117
+
l.Error("failed to build profile card", "err", err)
118
+
s.pages.Error500(w)
94
119
return
95
120
}
121
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
96
122
97
-
id := pageWithProfile.Id
98
123
repos, err := db.GetRepos(
99
124
s.db,
100
125
0,
101
-
db.FilterEq("did", id.DID),
126
+
db.FilterEq("did", profile.UserDid),
102
127
)
103
128
if err != nil {
104
-
log.Printf("getting repos for %s: %s", id.DID, err)
129
+
l.Error("failed to fetch repos", "err", err)
105
130
}
106
131
107
-
profile := pageWithProfile.Card.Profile
108
132
// filter out ones that are pinned
109
133
pinnedRepos := []db.Repo{}
110
134
for i, r := range repos {
111
135
// if this is a pinned repo, add it
112
-
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
136
+
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
113
137
pinnedRepos = append(pinnedRepos, r)
114
138
}
115
139
116
140
// if there are no saved pins, add the first 4 repos
117
-
if profile.IsPinnedReposEmpty() && i < 4 {
141
+
if profile.Profile.IsPinnedReposEmpty() && i < 4 {
118
142
pinnedRepos = append(pinnedRepos, r)
119
143
}
120
144
}
121
145
122
-
collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String())
146
+
collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid)
123
147
if err != nil {
124
-
log.Printf("getting collaborating repos for %s: %s", id.DID, err)
148
+
l.Error("failed to fetch collaborating repos", "err", err)
125
149
}
126
150
127
151
pinnedCollaboratingRepos := []db.Repo{}
128
152
for _, r := range collaboratingRepos {
129
153
// if this is a pinned repo, add it
130
-
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
154
+
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
131
155
pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r)
132
156
}
133
157
}
134
158
135
-
timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
159
+
timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid)
136
160
if err != nil {
137
-
log.Printf("failed to create profile timeline for %s: %s", id.DID, err)
161
+
l.Error("failed to create timeline", "err", err)
138
162
}
139
163
140
-
var didsToResolve []string
141
-
for _, r := range collaboratingRepos {
142
-
didsToResolve = append(didsToResolve, r.Did)
164
+
s.pages.ProfileOverview(w, pages.ProfileOverviewParams{
165
+
LoggedInUser: s.oauth.GetUser(r),
166
+
Card: profile,
167
+
Repos: pinnedRepos,
168
+
CollaboratingRepos: pinnedCollaboratingRepos,
169
+
ProfileTimeline: timeline,
170
+
})
171
+
}
172
+
173
+
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
174
+
l := s.logger.With("handler", "reposPage")
175
+
176
+
profile, err := s.profile(r)
177
+
if err != nil {
178
+
l.Error("failed to build profile card", "err", err)
179
+
s.pages.Error500(w)
180
+
return
143
181
}
144
-
for _, byMonth := range timeline.ByMonth {
145
-
for _, pe := range byMonth.PullEvents.Items {
146
-
didsToResolve = append(didsToResolve, pe.Repo.Did)
147
-
}
148
-
for _, ie := range byMonth.IssueEvents.Items {
149
-
didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
150
-
}
151
-
for _, re := range byMonth.RepoEvents {
152
-
didsToResolve = append(didsToResolve, re.Repo.Did)
153
-
if re.Source != nil {
154
-
didsToResolve = append(didsToResolve, re.Source.Did)
155
-
}
156
-
}
157
-
}
182
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
158
183
159
-
now := time.Now()
160
-
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
161
-
punchcard, err := db.MakePunchcard(
184
+
repos, err := db.GetRepos(
162
185
s.db,
163
-
db.FilterEq("did", id.DID),
164
-
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
165
-
db.FilterLte("date", now.Format(time.DateOnly)),
186
+
0,
187
+
db.FilterEq("did", profile.UserDid),
166
188
)
167
189
if err != nil {
168
-
log.Println("failed to get punchcard for did", "did", id.DID, "err", err)
190
+
l.Error("failed to get repos", "err", err)
191
+
s.pages.Error500(w)
192
+
return
169
193
}
170
194
171
-
s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{
172
-
LoggedInUser: pageWithProfile.LoggedInUser,
173
-
Repos: pinnedRepos,
174
-
CollaboratingRepos: pinnedCollaboratingRepos,
175
-
Card: pageWithProfile.Card,
176
-
Punchcard: punchcard,
177
-
ProfileTimeline: timeline,
195
+
err = s.pages.ProfileRepos(w, pages.ProfileReposParams{
196
+
LoggedInUser: s.oauth.GetUser(r),
197
+
Repos: repos,
198
+
Card: profile,
178
199
})
179
200
}
180
201
181
-
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
182
-
pageWithProfile := s.profilePage(w, r)
183
-
if pageWithProfile == nil {
202
+
func (s *State) starredPage(w http.ResponseWriter, r *http.Request) {
203
+
l := s.logger.With("handler", "starredPage")
204
+
205
+
profile, err := s.profile(r)
206
+
if err != nil {
207
+
l.Error("failed to build profile card", "err", err)
208
+
s.pages.Error500(w)
184
209
return
185
210
}
211
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
186
212
187
-
id := pageWithProfile.Id
213
+
stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid))
214
+
if err != nil {
215
+
l.Error("failed to get stars", "err", err)
216
+
s.pages.Error500(w)
217
+
return
218
+
}
219
+
var repoAts []string
220
+
for _, s := range stars {
221
+
repoAts = append(repoAts, string(s.RepoAt))
222
+
}
223
+
188
224
repos, err := db.GetRepos(
189
225
s.db,
190
226
0,
191
-
db.FilterEq("did", id.DID),
227
+
db.FilterIn("at_uri", repoAts),
192
228
)
193
229
if err != nil {
194
-
log.Printf("getting repos for %s: %s", id.DID, err)
230
+
l.Error("failed to get repos", "err", err)
231
+
s.pages.Error500(w)
232
+
return
195
233
}
196
234
197
-
s.pages.ReposPage(w, pages.ReposPageParams{
198
-
LoggedInUser: pageWithProfile.LoggedInUser,
235
+
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
236
+
LoggedInUser: s.oauth.GetUser(r),
199
237
Repos: repos,
200
-
Card: pageWithProfile.Card,
238
+
Card: profile,
239
+
})
240
+
}
241
+
242
+
func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) {
243
+
l := s.logger.With("handler", "stringsPage")
244
+
245
+
profile, err := s.profile(r)
246
+
if err != nil {
247
+
l.Error("failed to build profile card", "err", err)
248
+
s.pages.Error500(w)
249
+
return
250
+
}
251
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
252
+
253
+
strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid))
254
+
if err != nil {
255
+
l.Error("failed to get strings", "err", err)
256
+
s.pages.Error500(w)
257
+
return
258
+
}
259
+
260
+
err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{
261
+
LoggedInUser: s.oauth.GetUser(r),
262
+
Strings: strings,
263
+
Card: profile,
201
264
})
202
265
}
203
266
204
267
type FollowsPageParams struct {
205
-
LoggedInUser *oauth.User
206
-
Follows []pages.FollowCard
207
-
Card pages.ProfileCard
268
+
Follows []pages.FollowCard
269
+
Card *pages.ProfileCard
208
270
}
209
271
210
-
func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) {
211
-
pageWithProfile := s.profilePage(w, r)
212
-
if pageWithProfile == nil {
213
-
return FollowsPageParams{}, nil
272
+
func (s *State) followPage(
273
+
r *http.Request,
274
+
fetchFollows func(db.Execer, string) ([]db.Follow, error),
275
+
extractDid func(db.Follow) string,
276
+
) (*FollowsPageParams, error) {
277
+
l := s.logger.With("handler", "reposPage")
278
+
279
+
profile, err := s.profile(r)
280
+
if err != nil {
281
+
return nil, err
214
282
}
283
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
215
284
216
-
id := pageWithProfile.Id
217
-
loggedInUser := pageWithProfile.LoggedInUser
285
+
loggedInUser := s.oauth.GetUser(r)
286
+
params := FollowsPageParams{
287
+
Card: profile,
288
+
}
218
289
219
-
follows, err := fetchFollows(s.db, id.DID.String())
290
+
follows, err := fetchFollows(s.db, profile.UserDid)
220
291
if err != nil {
221
-
log.Printf("getting followers for %s: %s", id.DID, err)
222
-
return FollowsPageParams{}, err
292
+
l.Error("failed to fetch follows", "err", err)
293
+
return ¶ms, err
223
294
}
224
295
225
296
if len(follows) == 0 {
226
-
return FollowsPageParams{
227
-
LoggedInUser: loggedInUser,
228
-
Follows: []pages.FollowCard{},
229
-
Card: pageWithProfile.Card,
230
-
}, nil
297
+
return ¶ms, nil
231
298
}
232
299
233
300
followDids := make([]string, 0, len(follows))
···
237
304
238
305
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
239
306
if err != nil {
240
-
log.Printf("getting profile for %s: %s", followDids, err)
241
-
return FollowsPageParams{}, err
307
+
l.Error("failed to get profiles", "followDids", followDids, "err", err)
308
+
return ¶ms, err
242
309
}
243
310
244
311
followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
···
246
313
log.Printf("getting follow counts for %s: %s", followDids, err)
247
314
}
248
315
249
-
var loggedInUserFollowing map[string]struct{}
316
+
loggedInUserFollowing := make(map[string]struct{})
250
317
if loggedInUser != nil {
251
318
following, err := db.GetFollowing(s.db, loggedInUser.Did)
252
319
if err != nil {
253
-
return FollowsPageParams{}, err
320
+
l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did)
321
+
return ¶ms, err
254
322
}
255
-
if len(following) > 0 {
256
-
loggedInUserFollowing = make(map[string]struct{}, len(following))
257
-
for _, follow := range following {
258
-
loggedInUserFollowing[follow.SubjectDid] = struct{}{}
259
-
}
323
+
loggedInUserFollowing = make(map[string]struct{}, len(following))
324
+
for _, follow := range following {
325
+
loggedInUserFollowing[follow.SubjectDid] = struct{}{}
260
326
}
261
327
}
262
328
263
-
followCards := make([]pages.FollowCard, 0, len(follows))
264
-
for _, did := range followDids {
265
-
followStats, exists := followStatsMap[did]
266
-
if !exists {
267
-
followStats = db.FollowStats{}
268
-
}
329
+
followCards := make([]pages.FollowCard, len(follows))
330
+
for i, did := range followDids {
331
+
followStats := followStatsMap[did]
269
332
followStatus := db.IsNotFollowing
270
-
if loggedInUserFollowing != nil {
271
-
if _, exists := loggedInUserFollowing[did]; exists {
272
-
followStatus = db.IsFollowing
273
-
} else if loggedInUser.Did == did {
274
-
followStatus = db.IsSelf
275
-
}
333
+
if _, exists := loggedInUserFollowing[did]; exists {
334
+
followStatus = db.IsFollowing
335
+
} else if loggedInUser != nil && loggedInUser.Did == did {
336
+
followStatus = db.IsSelf
276
337
}
338
+
277
339
var profile *db.Profile
278
340
if p, exists := profiles[did]; exists {
279
341
profile = p
···
281
343
profile = &db.Profile{}
282
344
profile.Did = did
283
345
}
284
-
followCards = append(followCards, pages.FollowCard{
346
+
followCards[i] = pages.FollowCard{
285
347
UserDid: did,
286
348
FollowStatus: followStatus,
287
349
FollowersCount: followStats.Followers,
288
350
FollowingCount: followStats.Following,
289
351
Profile: profile,
290
-
})
352
+
}
291
353
}
292
354
293
-
return FollowsPageParams{
294
-
LoggedInUser: loggedInUser,
295
-
Follows: followCards,
296
-
Card: pageWithProfile.Card,
297
-
}, nil
355
+
params.Follows = followCards
356
+
357
+
return ¶ms, nil
298
358
}
299
359
300
360
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
301
-
followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
361
+
followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
302
362
if err != nil {
303
363
s.pages.Notice(w, "all-followers", "Failed to load followers")
304
364
return
305
365
}
306
366
307
-
s.pages.FollowersPage(w, pages.FollowersPageParams{
308
-
LoggedInUser: followPage.LoggedInUser,
367
+
s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{
368
+
LoggedInUser: s.oauth.GetUser(r),
309
369
Followers: followPage.Follows,
310
370
Card: followPage.Card,
311
371
})
312
372
}
313
373
314
374
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
315
-
followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
375
+
followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
316
376
if err != nil {
317
377
s.pages.Notice(w, "all-following", "Failed to load following")
318
378
return
319
379
}
320
380
321
-
s.pages.FollowingPage(w, pages.FollowingPageParams{
322
-
LoggedInUser: followPage.LoggedInUser,
381
+
s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{
382
+
LoggedInUser: s.oauth.GetUser(r),
323
383
Following: followPage.Follows,
324
384
Card: followPage.Card,
325
385
})
···
408
468
409
469
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
410
470
for _, issue := range issues {
411
-
owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
471
+
owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did)
412
472
if err != nil {
413
473
return err
414
474
}
···
440
500
441
501
func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
442
502
return &feeds.Item{
443
-
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name),
444
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
503
+
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name),
504
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
445
505
Created: issue.Created,
446
506
Author: author,
447
507
}
···
642
702
log.Printf("getting profile data for %s: %s", user.Did, err)
643
703
}
644
704
645
-
repos, err := db.GetAllReposByDid(s.db, user.Did)
705
+
repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did))
646
706
if err != nil {
647
707
log.Printf("getting repos for %s: %s", user.Did, err)
648
708
}
+4
-2
appview/state/router.go
+4
-2
appview/state/router.go
···
111
111
112
112
r.Handle("/static/*", s.pages.Static())
113
113
114
-
r.Get("/", s.Timeline)
114
+
r.Get("/", s.HomeOrTimeline)
115
+
r.Get("/timeline", s.Timeline)
116
+
r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner)
115
117
116
118
r.Route("/repo", func(r chi.Router) {
117
119
r.Route("/new", func(r chi.Router) {
···
230
232
}
231
233
232
234
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
233
-
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
235
+
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator)
234
236
return issues.Router(mw)
235
237
}
236
238
+76
-4
appview/state/state.go
+76
-4
appview/state/state.go
···
28
28
"tangled.sh/tangled.sh/core/appview/pages"
29
29
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
30
30
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
+
"tangled.sh/tangled.sh/core/appview/validator"
31
32
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
32
33
"tangled.sh/tangled.sh/core/eventconsumer"
33
34
"tangled.sh/tangled.sh/core/idresolver"
···
53
54
knotstream *eventconsumer.Consumer
54
55
spindlestream *eventconsumer.Consumer
55
56
logger *slog.Logger
57
+
validator *validator.Validator
56
58
}
57
59
58
60
func Make(ctx context.Context, config *config.Config) (*State, error) {
···
73
75
}
74
76
75
77
pgs := pages.NewPages(config, res)
76
-
77
78
cache := cache.New(config.Redis.Addr)
78
79
sess := session.New(cache)
79
-
80
80
oauth := oauth.NewOAuth(config, sess)
81
+
validator := validator.New(d)
81
82
82
83
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
83
84
if err != nil {
···
121
122
IdResolver: res,
122
123
Config: config,
123
124
Logger: tlog.New("ingester"),
125
+
Validator: validator,
124
126
}
125
127
err = jc.StartJetstream(ctx, ingester.Ingest())
126
128
if err != nil {
···
160
162
knotstream,
161
163
spindlestream,
162
164
slog.Default(),
165
+
validator,
163
166
}
164
167
165
168
return state, nil
166
169
}
167
170
171
+
func (s *State) Close() error {
172
+
// other close up logic goes here
173
+
return s.db.Close()
174
+
}
175
+
168
176
func (s *State) Favicon(w http.ResponseWriter, r *http.Request) {
169
177
w.Header().Set("Content-Type", "image/svg+xml")
170
178
w.Header().Set("Cache-Control", "public, max-age=31536000") // one year
···
190
198
s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{
191
199
LoggedInUser: user,
192
200
})
201
+
}
202
+
203
+
func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
204
+
if s.oauth.GetUser(r) != nil {
205
+
s.Timeline(w, r)
206
+
return
207
+
}
208
+
s.Home(w, r)
193
209
}
194
210
195
211
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
196
212
user := s.oauth.GetUser(r)
197
213
198
-
timeline, err := db.MakeTimeline(s.db)
214
+
timeline, err := db.MakeTimeline(s.db, 50)
199
215
if err != nil {
200
216
log.Println(err)
201
217
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
215
231
})
216
232
}
217
233
234
+
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
235
+
user := s.oauth.GetUser(r)
236
+
l := s.logger.With("handler", "UpgradeBanner")
237
+
l = l.With("did", user.Did)
238
+
l = l.With("handle", user.Handle)
239
+
240
+
regs, err := db.GetRegistrations(
241
+
s.db,
242
+
db.FilterEq("did", user.Did),
243
+
db.FilterEq("needs_upgrade", 1),
244
+
)
245
+
if err != nil {
246
+
l.Error("non-fatal: failed to get registrations", "err", err)
247
+
}
248
+
249
+
spindles, err := db.GetSpindles(
250
+
s.db,
251
+
db.FilterEq("owner", user.Did),
252
+
db.FilterEq("needs_upgrade", 1),
253
+
)
254
+
if err != nil {
255
+
l.Error("non-fatal: failed to get spindles", "err", err)
256
+
}
257
+
258
+
if regs == nil && spindles == nil {
259
+
return
260
+
}
261
+
262
+
s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{
263
+
Registrations: regs,
264
+
Spindles: spindles,
265
+
})
266
+
}
267
+
268
+
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
269
+
timeline, err := db.MakeTimeline(s.db, 5)
270
+
if err != nil {
271
+
log.Println(err)
272
+
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
273
+
return
274
+
}
275
+
276
+
repos, err := db.GetTopStarredReposLastWeek(s.db)
277
+
if err != nil {
278
+
log.Println(err)
279
+
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
280
+
return
281
+
}
282
+
283
+
s.pages.Home(w, pages.TimelineParams{
284
+
LoggedInUser: nil,
285
+
Timeline: timeline,
286
+
Repos: repos,
287
+
})
288
+
}
289
+
218
290
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
219
291
user := chi.URLParam(r, "user")
220
292
user = strings.TrimPrefix(user, "@")
···
243
315
244
316
for _, k := range pubKeys {
245
317
key := strings.TrimRight(k.Key, "\n")
246
-
w.Write([]byte(fmt.Sprintln(key)))
318
+
fmt.Fprintln(w, key)
247
319
}
248
320
}
249
321
+1
-59
appview/strings/strings.go
+1
-59
appview/strings/strings.go
···
5
5
"log/slog"
6
6
"net/http"
7
7
"path"
8
-
"slices"
9
8
"strconv"
10
9
"time"
11
10
···
161
160
}
162
161
163
162
func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
164
-
l := s.Logger.With("handler", "dashboard")
165
-
166
-
id, ok := r.Context().Value("resolvedId").(identity.Identity)
167
-
if !ok {
168
-
l.Error("malformed middleware")
169
-
w.WriteHeader(http.StatusInternalServerError)
170
-
return
171
-
}
172
-
l = l.With("did", id.DID, "handle", id.Handle)
173
-
174
-
all, err := db.GetStrings(
175
-
s.Db,
176
-
0,
177
-
db.FilterEq("did", id.DID),
178
-
)
179
-
if err != nil {
180
-
l.Error("failed to fetch strings", "err", err)
181
-
w.WriteHeader(http.StatusInternalServerError)
182
-
return
183
-
}
184
-
185
-
slices.SortFunc(all, func(a, b db.String) int {
186
-
if a.Created.After(b.Created) {
187
-
return -1
188
-
} else {
189
-
return 1
190
-
}
191
-
})
192
-
193
-
profile, err := db.GetProfile(s.Db, id.DID.String())
194
-
if err != nil {
195
-
l.Error("failed to fetch user profile", "err", err)
196
-
w.WriteHeader(http.StatusInternalServerError)
197
-
return
198
-
}
199
-
loggedInUser := s.OAuth.GetUser(r)
200
-
followStatus := db.IsNotFollowing
201
-
if loggedInUser != nil {
202
-
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
203
-
}
204
-
205
-
followStats, err := db.GetFollowerFollowingCount(s.Db, id.DID.String())
206
-
if err != nil {
207
-
l.Error("failed to get follow stats", "err", err)
208
-
}
209
-
210
-
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
211
-
LoggedInUser: s.OAuth.GetUser(r),
212
-
Card: pages.ProfileCard{
213
-
UserDid: id.DID.String(),
214
-
UserHandle: id.Handle.String(),
215
-
Profile: profile,
216
-
FollowStatus: followStatus,
217
-
FollowersCount: followStats.Followers,
218
-
FollowingCount: followStats.Following,
219
-
},
220
-
Strings: all,
221
-
})
163
+
http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound)
222
164
}
223
165
224
166
func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
+53
appview/validator/issue.go
+53
appview/validator/issue.go
···
1
+
package validator
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"tangled.sh/tangled.sh/core/appview/db"
8
+
)
9
+
10
+
func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error {
11
+
// if comments have parents, only ingest ones that are 1 level deep
12
+
if comment.ReplyTo != nil {
13
+
parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo))
14
+
if err != nil {
15
+
return fmt.Errorf("failed to fetch parent comment: %w", err)
16
+
}
17
+
if len(parents) != 1 {
18
+
return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents))
19
+
}
20
+
21
+
// depth check
22
+
parent := parents[0]
23
+
if parent.ReplyTo != nil {
24
+
return fmt.Errorf("incorrect depth, this comment is replying at depth >1")
25
+
}
26
+
}
27
+
28
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" {
29
+
return fmt.Errorf("body is empty after HTML sanitization")
30
+
}
31
+
32
+
return nil
33
+
}
34
+
35
+
func (v *Validator) ValidateIssue(issue *db.Issue) error {
36
+
if issue.Title == "" {
37
+
return fmt.Errorf("issue title is empty")
38
+
}
39
+
40
+
if issue.Body == "" {
41
+
return fmt.Errorf("issue body is empty")
42
+
}
43
+
44
+
if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" {
45
+
return fmt.Errorf("title is empty after HTML sanitization")
46
+
}
47
+
48
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" {
49
+
return fmt.Errorf("body is empty after HTML sanitization")
50
+
}
51
+
52
+
return nil
53
+
}
+18
appview/validator/validator.go
+18
appview/validator/validator.go
···
1
+
package validator
2
+
3
+
import (
4
+
"tangled.sh/tangled.sh/core/appview/db"
5
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
6
+
)
7
+
8
+
type Validator struct {
9
+
db *db.DB
10
+
sanitizer markup.Sanitizer
11
+
}
12
+
13
+
func New(db *db.DB) *Validator {
14
+
return &Validator{
15
+
db: db,
16
+
sanitizer: markup.NewSanitizer(),
17
+
}
18
+
}
+11
-5
appview/xrpcclient/xrpc.go
+11
-5
appview/xrpcclient/xrpc.go
···
4
4
"bytes"
5
5
"context"
6
6
"errors"
7
-
"fmt"
8
7
"io"
9
8
"net/http"
10
9
···
12
11
"github.com/bluesky-social/indigo/xrpc"
13
12
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
14
13
oauth "tangled.sh/icyphox.sh/atproto-oauth"
14
+
)
15
+
16
+
var (
17
+
ErrXrpcUnsupported = errors.New("xrpc not supported on this knot")
18
+
ErrXrpcUnauthorized = errors.New("unauthorized xrpc request")
19
+
ErrXrpcFailed = errors.New("xrpc request failed")
20
+
ErrXrpcInvalid = errors.New("invalid xrpc request")
15
21
)
16
22
17
23
type Client struct {
···
115
121
116
122
var xrpcerr *indigoxrpc.Error
117
123
if ok := errors.As(err, &xrpcerr); !ok {
118
-
return fmt.Errorf("Recieved invalid XRPC error response.")
124
+
return ErrXrpcInvalid
119
125
}
120
126
121
127
switch xrpcerr.StatusCode {
122
128
case http.StatusNotFound:
123
-
return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.")
129
+
return ErrXrpcUnsupported
124
130
case http.StatusUnauthorized:
125
-
return fmt.Errorf("Unauthorized XRPC request.")
131
+
return ErrXrpcUnauthorized
126
132
default:
127
-
return fmt.Errorf("Failed to perform operation. Try again later.")
133
+
return ErrXrpcFailed
128
134
}
129
135
}
+3
cmd/appview/main.go
+3
cmd/appview/main.go
+53
-12
docs/hacking.md
+53
-12
docs/hacking.md
···
48
48
redis-server
49
49
```
50
50
51
-
## running a knot
51
+
## running knots and spindles
52
52
53
53
An end-to-end knot setup requires setting up a machine with
54
54
`sshd`, `AuthorizedKeysCommand`, and git user, which is
55
55
quite cumbersome. So the nix flake provides a
56
56
`nixosConfiguration` to do so.
57
57
58
-
To begin, grab your DID from http://localhost:3000/settings.
59
-
Then, set `TANGLED_VM_KNOT_OWNER` and
60
-
`TANGLED_VM_SPINDLE_OWNER` to your DID.
58
+
<details>
59
+
<summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary>
60
+
61
+
In order to build Tangled's dev VM on macOS, you will
62
+
first need to set up a Linux Nix builder. The recommended
63
+
way to do so is to run a [`darwin.linux-builder`
64
+
VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
65
+
and to register it in `nix.conf` as a builder for Linux
66
+
with the same architecture as your Mac (`linux-aarch64` if
67
+
you are using Apple Silicon).
68
+
69
+
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
70
+
> the tangled repo so that it doesn't conflict with the other VM. For example,
71
+
> you can do
72
+
>
73
+
> ```shell
74
+
> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
75
+
> ```
76
+
>
77
+
> to store the builder VM in a temporary dir.
78
+
>
79
+
> You should read and follow [all the other intructions][darwin builder vm] to
80
+
> avoid subtle problems.
81
+
82
+
Alternatively, you can use any other method to set up a
83
+
Linux machine with `nix` installed that you can `sudo ssh`
84
+
into (in other words, root user on your Mac has to be able
85
+
to ssh into the Linux machine without entering a password)
86
+
and that has the same architecture as your Mac. See
87
+
[remote builder
88
+
instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
89
+
for how to register such a builder in `nix.conf`.
61
90
62
-
If you don't want to [set up a spindle](#running-a-spindle),
63
-
you can use any placeholder value.
91
+
> WARNING: If you'd like to use
92
+
> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
93
+
> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
94
+
> ssh` works can be tricky. It seems to be [possible with
95
+
> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
64
96
65
-
You can now start a lightweight NixOS VM like so:
97
+
</details>
98
+
99
+
To begin, grab your DID from http://localhost:3000/settings.
100
+
Then, set `TANGLED_VM_KNOT_OWNER` and
101
+
`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
102
+
lightweight NixOS VM like so:
66
103
67
104
```bash
68
105
nix run --impure .#vm
···
74
111
with `ssh` exposed on port 2222.
75
112
76
113
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.
114
+
http://localhost:3000/knots and hit verify. It should
115
+
verify the ownership of the services instantly if everything
116
+
went smoothly.
81
117
82
118
You can push repositories to this VM with this ssh config
83
119
block on your main machine:
···
97
133
git push local-dev main
98
134
```
99
135
100
-
## running a spindle
136
+
### running a spindle
101
137
102
138
The above VM should already be running a spindle on
103
139
`localhost:6555`. Head to http://localhost:3000/spindles and
···
119
155
# litecli has a nicer REPL interface:
120
156
litecli /var/lib/spindle/spindle.db
121
157
```
158
+
159
+
If for any reason you wish to disable either one of the
160
+
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
161
+
`services.tangled-spindle.enable` (or
162
+
`services.tangled-knot.enable`) to `false`.
-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
-
```
+60
docs/migrations.md
+60
docs/migrations.md
···
1
+
# Migrations
2
+
3
+
This document is laid out in reverse-chronological order.
4
+
Newer migration guides are listed first, and older guides
5
+
are further down the page.
6
+
7
+
## Upgrading from v1.8.x
8
+
9
+
After v1.8.2, the HTTP API for knot and spindles have been
10
+
deprecated and replaced with XRPC. Repositories on outdated
11
+
knots will not be viewable from the appview. Upgrading is
12
+
straightforward however.
13
+
14
+
For knots:
15
+
16
+
- Upgrade to latest tag (v1.9.0 or above)
17
+
- Head to the [knot dashboard](https://tangled.sh/knots) and
18
+
hit the "retry" button to verify your knot
19
+
20
+
For spindles:
21
+
22
+
- Upgrade to latest tag (v1.9.0 or above)
23
+
- Head to the [spindle
24
+
dashboard](https://tangled.sh/spindles) and hit the
25
+
"retry" button to verify your spindle
26
+
27
+
## Upgrading from v1.7.x
28
+
29
+
After v1.7.0, knot secrets have been deprecated. You no
30
+
longer need a secret from the appview to run a knot. All
31
+
authorized commands to knots are managed via [Inter-Service
32
+
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
33
+
Knots will be read-only until upgraded.
34
+
35
+
Upgrading is quite easy, in essence:
36
+
37
+
- `KNOT_SERVER_SECRET` is no more, you can remove this
38
+
environment variable entirely
39
+
- `KNOT_SERVER_OWNER` is now required on boot, set this to
40
+
your DID. You can find your DID in the
41
+
[settings](https://tangled.sh/settings) page.
42
+
- Restart your knot once you have replaced the environment
43
+
variable
44
+
- Head to the [knot dashboard](https://tangled.sh/knots) and
45
+
hit the "retry" button to verify your knot. This simply
46
+
writes a `sh.tangled.knot` record to your PDS.
47
+
48
+
If you use the nix module, simply bump the flake to the
49
+
latest revision, and change your config block like so:
50
+
51
+
```diff
52
+
services.tangled-knot = {
53
+
enable = true;
54
+
server = {
55
+
- secretFile = /path/to/secret;
56
+
+ owner = "did:plc:foo";
57
+
};
58
+
};
59
+
```
60
+
+1
-1
input.css
+1
-1
input.css
···
90
90
}
91
91
92
92
label {
93
-
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
93
+
@apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
94
94
}
95
95
input {
96
96
@apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
-285
knotclient/unsigned.go
-285
knotclient/unsigned.go
···
1
-
package knotclient
2
-
3
-
import (
4
-
"bytes"
5
-
"encoding/json"
6
-
"fmt"
7
-
"io"
8
-
"log"
9
-
"net/http"
10
-
"net/url"
11
-
"strconv"
12
-
"time"
13
-
14
-
"tangled.sh/tangled.sh/core/types"
15
-
)
16
-
17
-
type UnsignedClient struct {
18
-
Url *url.URL
19
-
client *http.Client
20
-
}
21
-
22
-
func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) {
23
-
client := &http.Client{
24
-
Timeout: 5 * time.Second,
25
-
}
26
-
27
-
scheme := "https"
28
-
if dev {
29
-
scheme = "http"
30
-
}
31
-
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
32
-
if err != nil {
33
-
return nil, err
34
-
}
35
-
36
-
unsignedClient := &UnsignedClient{
37
-
client: client,
38
-
Url: url,
39
-
}
40
-
41
-
return unsignedClient, nil
42
-
}
43
-
44
-
func (us *UnsignedClient) newRequest(method, endpoint string, query url.Values, body []byte) (*http.Request, error) {
45
-
reqUrl := us.Url.JoinPath(endpoint)
46
-
47
-
// add query parameters
48
-
if query != nil {
49
-
reqUrl.RawQuery = query.Encode()
50
-
}
51
-
52
-
return http.NewRequest(method, reqUrl.String(), bytes.NewReader(body))
53
-
}
54
-
55
-
func do[T any](us *UnsignedClient, req *http.Request) (*T, error) {
56
-
resp, err := us.client.Do(req)
57
-
if err != nil {
58
-
return nil, err
59
-
}
60
-
defer resp.Body.Close()
61
-
62
-
body, err := io.ReadAll(resp.Body)
63
-
if err != nil {
64
-
log.Printf("Error reading response body: %v", err)
65
-
return nil, err
66
-
}
67
-
68
-
var result T
69
-
err = json.Unmarshal(body, &result)
70
-
if err != nil {
71
-
log.Printf("Error unmarshalling response body: %v", err)
72
-
return nil, err
73
-
}
74
-
75
-
return &result, nil
76
-
}
77
-
78
-
func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*types.RepoIndexResponse, error) {
79
-
const (
80
-
Method = "GET"
81
-
)
82
-
83
-
endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref)
84
-
if ref == "" {
85
-
endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName)
86
-
}
87
-
88
-
req, err := us.newRequest(Method, endpoint, nil, nil)
89
-
if err != nil {
90
-
return nil, err
91
-
}
92
-
93
-
return do[types.RepoIndexResponse](us, req)
94
-
}
95
-
96
-
func (us *UnsignedClient) Log(ownerDid, repoName, ref string, page int) (*types.RepoLogResponse, error) {
97
-
const (
98
-
Method = "GET"
99
-
)
100
-
101
-
endpoint := fmt.Sprintf("/%s/%s/log/%s", ownerDid, repoName, url.PathEscape(ref))
102
-
103
-
query := url.Values{}
104
-
query.Add("page", strconv.Itoa(page))
105
-
query.Add("per_page", strconv.Itoa(60))
106
-
107
-
req, err := us.newRequest(Method, endpoint, query, nil)
108
-
if err != nil {
109
-
return nil, err
110
-
}
111
-
112
-
return do[types.RepoLogResponse](us, req)
113
-
}
114
-
115
-
func (us *UnsignedClient) Branches(ownerDid, repoName string) (*types.RepoBranchesResponse, error) {
116
-
const (
117
-
Method = "GET"
118
-
)
119
-
120
-
endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName)
121
-
122
-
req, err := us.newRequest(Method, endpoint, nil, nil)
123
-
if err != nil {
124
-
return nil, err
125
-
}
126
-
127
-
return do[types.RepoBranchesResponse](us, req)
128
-
}
129
-
130
-
func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) {
131
-
const (
132
-
Method = "GET"
133
-
)
134
-
135
-
endpoint := fmt.Sprintf("/%s/%s/tags", ownerDid, repoName)
136
-
137
-
req, err := us.newRequest(Method, endpoint, nil, nil)
138
-
if err != nil {
139
-
return nil, err
140
-
}
141
-
142
-
return do[types.RepoTagsResponse](us, req)
143
-
}
144
-
145
-
func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*types.RepoBranchResponse, error) {
146
-
const (
147
-
Method = "GET"
148
-
)
149
-
150
-
endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, url.PathEscape(branch))
151
-
152
-
req, err := us.newRequest(Method, endpoint, nil, nil)
153
-
if err != nil {
154
-
return nil, err
155
-
}
156
-
157
-
return do[types.RepoBranchResponse](us, req)
158
-
}
159
-
160
-
func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*types.RepoDefaultBranchResponse, error) {
161
-
const (
162
-
Method = "GET"
163
-
)
164
-
165
-
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
166
-
167
-
req, err := us.newRequest(Method, endpoint, nil, nil)
168
-
if err != nil {
169
-
return nil, err
170
-
}
171
-
172
-
resp, err := us.client.Do(req)
173
-
if err != nil {
174
-
return nil, err
175
-
}
176
-
defer resp.Body.Close()
177
-
178
-
var defaultBranch types.RepoDefaultBranchResponse
179
-
if err := json.NewDecoder(resp.Body).Decode(&defaultBranch); err != nil {
180
-
return nil, err
181
-
}
182
-
183
-
return &defaultBranch, nil
184
-
}
185
-
186
-
func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) {
187
-
const (
188
-
Method = "GET"
189
-
Endpoint = "/capabilities"
190
-
)
191
-
192
-
req, err := us.newRequest(Method, Endpoint, nil, nil)
193
-
if err != nil {
194
-
return nil, err
195
-
}
196
-
197
-
resp, err := us.client.Do(req)
198
-
if err != nil {
199
-
return nil, err
200
-
}
201
-
defer resp.Body.Close()
202
-
203
-
var capabilities types.Capabilities
204
-
if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil {
205
-
return nil, err
206
-
}
207
-
208
-
return &capabilities, nil
209
-
}
210
-
211
-
func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) {
212
-
const (
213
-
Method = "GET"
214
-
)
215
-
216
-
endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2))
217
-
218
-
req, err := us.newRequest(Method, endpoint, nil, nil)
219
-
if err != nil {
220
-
return nil, fmt.Errorf("Failed to create request.")
221
-
}
222
-
223
-
compareResp, err := us.client.Do(req)
224
-
if err != nil {
225
-
return nil, fmt.Errorf("Failed to create request.")
226
-
}
227
-
defer compareResp.Body.Close()
228
-
229
-
switch compareResp.StatusCode {
230
-
case 404:
231
-
case 400:
232
-
return nil, fmt.Errorf("Branch comparisons not supported on this knot.")
233
-
}
234
-
235
-
respBody, err := io.ReadAll(compareResp.Body)
236
-
if err != nil {
237
-
log.Println("failed to compare across branches")
238
-
return nil, fmt.Errorf("Failed to compare branches.")
239
-
}
240
-
defer compareResp.Body.Close()
241
-
242
-
var formatPatchResponse types.RepoFormatPatchResponse
243
-
err = json.Unmarshal(respBody, &formatPatchResponse)
244
-
if err != nil {
245
-
log.Println("failed to unmarshal format-patch response", err)
246
-
return nil, fmt.Errorf("failed to compare branches.")
247
-
}
248
-
249
-
return &formatPatchResponse, nil
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
-
}
+7
knotserver/config/config.go
+7
knotserver/config/config.go
···
27
27
Dev bool `env:"DEV, default=false"`
28
28
}
29
29
30
+
type Git struct {
31
+
// user name & email used as committer
32
+
UserName string `env:"USER_NAME, default=Tangled"`
33
+
UserEmail string `env:"USER_EMAIL, default=noreply@tangled.sh"`
34
+
}
35
+
30
36
func (s Server) Did() syntax.DID {
31
37
return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname))
32
38
}
···
34
40
type Config struct {
35
41
Repo Repo `env:",prefix=KNOT_REPO_"`
36
42
Server Server `env:",prefix=KNOT_SERVER_"`
43
+
Git Git `env:",prefix=KNOT_GIT_"`
37
44
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"`
38
45
}
39
46
+40
knotserver/db/pubkeys.go
+40
knotserver/db/pubkeys.go
···
1
1
package db
2
2
3
3
import (
4
+
"strconv"
4
5
"time"
5
6
6
7
"tangled.sh/tangled.sh/core/api/tangled"
···
99
100
100
101
return keys, nil
101
102
}
103
+
104
+
func (d *DB) GetPublicKeysPaginated(limit int, cursor string) ([]PublicKey, string, error) {
105
+
var keys []PublicKey
106
+
107
+
offset := 0
108
+
if cursor != "" {
109
+
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 {
110
+
offset = o
111
+
}
112
+
}
113
+
114
+
query := `select key, did, created from public_keys order by created desc limit ? offset ?`
115
+
rows, err := d.db.Query(query, limit+1, offset) // +1 to check if there are more results
116
+
if err != nil {
117
+
return nil, "", err
118
+
}
119
+
defer rows.Close()
120
+
121
+
for rows.Next() {
122
+
var publicKey PublicKey
123
+
if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.CreatedAt); err != nil {
124
+
return nil, "", err
125
+
}
126
+
keys = append(keys, publicKey)
127
+
}
128
+
129
+
if err := rows.Err(); err != nil {
130
+
return nil, "", err
131
+
}
132
+
133
+
// check if there are more results for pagination
134
+
var nextCursor string
135
+
if len(keys) > limit {
136
+
keys = keys[:limit] // remove the extra item
137
+
nextCursor = strconv.Itoa(offset + limit)
138
+
}
139
+
140
+
return keys, nextCursor, nil
141
+
}
+2
-2
knotserver/events.go
+2
-2
knotserver/events.go
···
15
15
WriteBufferSize: 1024,
16
16
}
17
17
18
-
func (h *Handle) Events(w http.ResponseWriter, r *http.Request) {
18
+
func (h *Knot) Events(w http.ResponseWriter, r *http.Request) {
19
19
l := h.l.With("handler", "OpLog")
20
20
l.Debug("received new connection")
21
21
···
83
83
}
84
84
}
85
85
86
-
func (h *Handle) streamOps(conn *websocket.Conn, cursor *int64) error {
86
+
func (h *Knot) streamOps(conn *websocket.Conn, cursor *int64) error {
87
87
events, err := h.db.GetEvents(*cursor)
88
88
if err != nil {
89
89
h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
-48
knotserver/file.go
-48
knotserver/file.go
···
1
-
package knotserver
2
-
3
-
import (
4
-
"bytes"
5
-
"io"
6
-
"log/slog"
7
-
"net/http"
8
-
"strings"
9
-
10
-
"tangled.sh/tangled.sh/core/types"
11
-
)
12
-
13
-
func countLines(r io.Reader) (int, error) {
14
-
buf := make([]byte, 32*1024)
15
-
bufLen := 0
16
-
count := 0
17
-
nl := []byte{'\n'}
18
-
19
-
for {
20
-
c, err := r.Read(buf)
21
-
if c > 0 {
22
-
bufLen += c
23
-
}
24
-
count += bytes.Count(buf[:c], nl)
25
-
26
-
switch {
27
-
case err == io.EOF:
28
-
/* handle last line not having a newline at the end */
29
-
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
30
-
count++
31
-
}
32
-
return count, nil
33
-
case err != nil:
34
-
return 0, err
35
-
}
36
-
}
37
-
}
38
-
39
-
func (h *Handle) showFile(resp types.RepoBlobResponse, w http.ResponseWriter, l *slog.Logger) {
40
-
lc, err := countLines(strings.NewReader(resp.Contents))
41
-
if err != nil {
42
-
// Non-fatal, we'll just skip showing line numbers in the template.
43
-
l.Warn("counting lines", "error", err)
44
-
}
45
-
46
-
resp.Lines = lc
47
-
writeJSON(w, resp)
48
-
}
+58
-72
knotserver/git/merge.go
+58
-72
knotserver/git/merge.go
···
12
12
"github.com/dgraph-io/ristretto"
13
13
"github.com/go-git/go-git/v5"
14
14
"github.com/go-git/go-git/v5/plumbing"
15
-
"tangled.sh/tangled.sh/core/patchutil"
16
15
)
17
16
18
17
type MergeCheckCache struct {
···
86
85
87
86
// MergeOptions specifies the configuration for a merge operation
88
87
type MergeOptions struct {
89
-
CommitMessage string
90
-
CommitBody string
91
-
AuthorName string
92
-
AuthorEmail string
93
-
FormatPatch bool
88
+
CommitMessage string
89
+
CommitBody string
90
+
AuthorName string
91
+
AuthorEmail string
92
+
CommitterName string
93
+
CommitterEmail string
94
+
FormatPatch bool
94
95
}
95
96
96
97
func (e ErrMerge) Error() string {
···
143
144
return tmpDir, nil
144
145
}
145
146
146
-
func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool, opts *MergeOptions) error {
147
+
func (g *GitRepo) checkPatch(tmpDir, patchFile string) error {
147
148
var stderr bytes.Buffer
148
-
var cmd *exec.Cmd
149
149
150
-
if checkOnly {
151
-
cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
152
-
} else {
153
-
// if patch is a format-patch, apply using 'git am'
154
-
if opts.FormatPatch {
155
-
amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile)
156
-
amCmd.Stderr = &stderr
157
-
if err := amCmd.Run(); err != nil {
158
-
return fmt.Errorf("patch application failed: %s", stderr.String())
159
-
}
160
-
return nil
150
+
cmd := exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
151
+
cmd.Stderr = &stderr
152
+
153
+
if err := cmd.Run(); err != nil {
154
+
conflicts := parseGitApplyErrors(stderr.String())
155
+
return &ErrMerge{
156
+
Message: "patch cannot be applied cleanly",
157
+
Conflicts: conflicts,
158
+
HasConflict: len(conflicts) > 0,
159
+
OtherError: err,
161
160
}
162
-
163
-
// else, apply using 'git apply' and commit it manually
164
-
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
165
-
if opts != nil {
166
-
applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
167
-
applyCmd.Stderr = &stderr
168
-
if err := applyCmd.Run(); err != nil {
169
-
return fmt.Errorf("patch application failed: %s", stderr.String())
170
-
}
161
+
}
162
+
return nil
163
+
}
171
164
172
-
stageCmd := exec.Command("git", "-C", tmpDir, "add", ".")
173
-
if err := stageCmd.Run(); err != nil {
174
-
return fmt.Errorf("failed to stage changes: %w", err)
175
-
}
165
+
func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error {
166
+
var stderr bytes.Buffer
167
+
var cmd *exec.Cmd
176
168
177
-
commitArgs := []string{"-C", tmpDir, "commit"}
169
+
// configure default git user before merge
170
+
exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run()
171
+
exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run()
172
+
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
178
173
179
-
// Set author if provided
180
-
authorName := opts.AuthorName
181
-
authorEmail := opts.AuthorEmail
174
+
// if patch is a format-patch, apply using 'git am'
175
+
if opts.FormatPatch {
176
+
cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
177
+
} else {
178
+
// else, apply using 'git apply' and commit it manually
179
+
applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
180
+
applyCmd.Stderr = &stderr
181
+
if err := applyCmd.Run(); err != nil {
182
+
return fmt.Errorf("patch application failed: %s", stderr.String())
183
+
}
182
184
183
-
if authorEmail == "" {
184
-
authorEmail = "noreply@tangled.sh"
185
-
}
185
+
stageCmd := exec.Command("git", "-C", tmpDir, "add", ".")
186
+
if err := stageCmd.Run(); err != nil {
187
+
return fmt.Errorf("failed to stage changes: %w", err)
188
+
}
186
189
187
-
if authorName == "" {
188
-
authorName = "Tangled"
189
-
}
190
+
commitArgs := []string{"-C", tmpDir, "commit"}
190
191
191
-
if authorName != "" {
192
-
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
193
-
}
192
+
// Set author if provided
193
+
authorName := opts.AuthorName
194
+
authorEmail := opts.AuthorEmail
194
195
195
-
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
196
+
if authorName != "" && authorEmail != "" {
197
+
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
198
+
}
199
+
// else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
196
200
197
-
if opts.CommitBody != "" {
198
-
commitArgs = append(commitArgs, "-m", opts.CommitBody)
199
-
}
201
+
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
200
202
201
-
cmd = exec.Command("git", commitArgs...)
202
-
} else {
203
-
// If no commit message specified, use git-am which automatically creates a commit
204
-
cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
203
+
if opts.CommitBody != "" {
204
+
commitArgs = append(commitArgs, "-m", opts.CommitBody)
205
205
}
206
+
207
+
cmd = exec.Command("git", commitArgs...)
206
208
}
207
209
208
210
cmd.Stderr = &stderr
209
211
210
212
if err := cmd.Run(); err != nil {
211
-
if checkOnly {
212
-
conflicts := parseGitApplyErrors(stderr.String())
213
-
return &ErrMerge{
214
-
Message: "patch cannot be applied cleanly",
215
-
Conflicts: conflicts,
216
-
HasConflict: len(conflicts) > 0,
217
-
OtherError: err,
218
-
}
219
-
}
220
213
return fmt.Errorf("patch application failed: %s", stderr.String())
221
214
}
222
215
···
227
220
if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
228
221
return val
229
222
}
230
-
231
-
var opts MergeOptions
232
-
opts.FormatPatch = patchutil.IsFormatPatch(string(patchData))
233
223
234
224
patchFile, err := g.createTempFileWithPatch(patchData)
235
225
if err != nil {
···
249
239
}
250
240
defer os.RemoveAll(tmpDir)
251
241
252
-
result := g.applyPatch(tmpDir, patchFile, true, &opts)
242
+
result := g.checkPatch(tmpDir, patchFile)
253
243
mergeCheckCache.Set(g, patchData, targetBranch, result)
254
244
return result
255
245
}
256
246
257
-
func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
258
-
return g.MergeWithOptions(patchData, targetBranch, nil)
259
-
}
260
-
261
-
func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts *MergeOptions) error {
247
+
func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error {
262
248
patchFile, err := g.createTempFileWithPatch(patchData)
263
249
if err != nil {
264
250
return &ErrMerge{
···
277
263
}
278
264
defer os.RemoveAll(tmpDir)
279
265
280
-
if err := g.applyPatch(tmpDir, patchFile, false, opts); err != nil {
266
+
if err := g.applyPatch(tmpDir, patchFile, opts); err != nil {
281
267
return err
282
268
}
283
269
+4
-4
knotserver/git.go
+4
-4
knotserver/git.go
···
13
13
"tangled.sh/tangled.sh/core/knotserver/git/service"
14
14
)
15
15
16
-
func (d *Handle) InfoRefs(w http.ResponseWriter, r *http.Request) {
16
+
func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
17
17
did := chi.URLParam(r, "did")
18
18
name := chi.URLParam(r, "name")
19
19
repoName, err := securejoin.SecureJoin(did, name)
···
56
56
}
57
57
}
58
58
59
-
func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) {
59
+
func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
60
60
did := chi.URLParam(r, "did")
61
61
name := chi.URLParam(r, "name")
62
62
repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
···
105
105
}
106
106
}
107
107
108
-
func (d *Handle) ReceivePack(w http.ResponseWriter, r *http.Request) {
108
+
func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) {
109
109
did := chi.URLParam(r, "did")
110
110
name := chi.URLParam(r, "name")
111
111
_, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
···
118
118
d.RejectPush(w, r, name)
119
119
}
120
120
121
-
func (d *Handle) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
121
+
func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
122
122
// A text/plain response will cause git to print each line of the body
123
123
// prefixed with "remote: ".
124
124
w.Header().Set("content-type", "text/plain; charset=UTF-8")
-1069
knotserver/handler.go
-1069
knotserver/handler.go
···
1
-
package knotserver
2
-
3
-
import (
4
-
"compress/gzip"
5
-
"context"
6
-
"crypto/sha256"
7
-
"encoding/json"
8
-
"errors"
9
-
"fmt"
10
-
"log"
11
-
"net/http"
12
-
"net/url"
13
-
"path/filepath"
14
-
"strconv"
15
-
"strings"
16
-
"sync"
17
-
"time"
18
-
19
-
securejoin "github.com/cyphar/filepath-securejoin"
20
-
"github.com/gliderlabs/ssh"
21
-
"github.com/go-chi/chi/v5"
22
-
"github.com/go-git/go-git/v5/plumbing"
23
-
"github.com/go-git/go-git/v5/plumbing/object"
24
-
"tangled.sh/tangled.sh/core/knotserver/db"
25
-
"tangled.sh/tangled.sh/core/knotserver/git"
26
-
"tangled.sh/tangled.sh/core/types"
27
-
)
28
-
29
-
func (h *Handle) Index(w http.ResponseWriter, r *http.Request) {
30
-
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
31
-
}
32
-
33
-
func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) {
34
-
w.Header().Set("Content-Type", "application/json")
35
-
36
-
capabilities := map[string]any{
37
-
"pull_requests": map[string]any{
38
-
"format_patch": true,
39
-
"patch_submissions": true,
40
-
"branch_submissions": true,
41
-
"fork_submissions": true,
42
-
},
43
-
"xrpc": true,
44
-
}
45
-
46
-
jsonData, err := json.Marshal(capabilities)
47
-
if err != nil {
48
-
http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError)
49
-
return
50
-
}
51
-
52
-
w.Write(jsonData)
53
-
}
54
-
55
-
func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) {
56
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
57
-
l := h.l.With("path", path, "handler", "RepoIndex")
58
-
ref := chi.URLParam(r, "ref")
59
-
ref, _ = url.PathUnescape(ref)
60
-
61
-
gr, err := git.Open(path, ref)
62
-
if err != nil {
63
-
plain, err2 := git.PlainOpen(path)
64
-
if err2 != nil {
65
-
l.Error("opening repo", "error", err2.Error())
66
-
notFound(w)
67
-
return
68
-
}
69
-
branches, _ := plain.Branches()
70
-
71
-
log.Println(err)
72
-
73
-
if errors.Is(err, plumbing.ErrReferenceNotFound) {
74
-
resp := types.RepoIndexResponse{
75
-
IsEmpty: true,
76
-
Branches: branches,
77
-
}
78
-
writeJSON(w, resp)
79
-
return
80
-
} else {
81
-
l.Error("opening repo", "error", err.Error())
82
-
notFound(w)
83
-
return
84
-
}
85
-
}
86
-
87
-
var (
88
-
commits []*object.Commit
89
-
total int
90
-
branches []types.Branch
91
-
files []types.NiceTree
92
-
tags []object.Tag
93
-
)
94
-
95
-
var wg sync.WaitGroup
96
-
errorsCh := make(chan error, 5)
97
-
98
-
wg.Add(1)
99
-
go func() {
100
-
defer wg.Done()
101
-
cs, err := gr.Commits(0, 60)
102
-
if err != nil {
103
-
errorsCh <- fmt.Errorf("commits: %w", err)
104
-
return
105
-
}
106
-
commits = cs
107
-
}()
108
-
109
-
wg.Add(1)
110
-
go func() {
111
-
defer wg.Done()
112
-
t, err := gr.TotalCommits()
113
-
if err != nil {
114
-
errorsCh <- fmt.Errorf("calculating total: %w", err)
115
-
return
116
-
}
117
-
total = t
118
-
}()
119
-
120
-
wg.Add(1)
121
-
go func() {
122
-
defer wg.Done()
123
-
bs, err := gr.Branches()
124
-
if err != nil {
125
-
errorsCh <- fmt.Errorf("fetching branches: %w", err)
126
-
return
127
-
}
128
-
branches = bs
129
-
}()
130
-
131
-
wg.Add(1)
132
-
go func() {
133
-
defer wg.Done()
134
-
ts, err := gr.Tags()
135
-
if err != nil {
136
-
errorsCh <- fmt.Errorf("fetching tags: %w", err)
137
-
return
138
-
}
139
-
tags = ts
140
-
}()
141
-
142
-
wg.Add(1)
143
-
go func() {
144
-
defer wg.Done()
145
-
fs, err := gr.FileTree(r.Context(), "")
146
-
if err != nil {
147
-
errorsCh <- fmt.Errorf("fetching filetree: %w", err)
148
-
return
149
-
}
150
-
files = fs
151
-
}()
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)
230
-
if err != nil {
231
-
notFound(w)
232
-
return
233
-
}
234
-
235
-
files, err := gr.FileTree(r.Context(), treePath)
236
-
if err != nil {
237
-
writeError(w, err.Error(), http.StatusInternalServerError)
238
-
l.Error("file tree", "error", err.Error())
239
-
return
240
-
}
241
-
242
-
resp := types.RepoTreeResponse{
243
-
Ref: ref,
244
-
Parent: treePath,
245
-
Description: getDescription(path),
246
-
DotDot: filepath.Dir(treePath),
247
-
Files: files,
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"
279
-
}
280
-
281
-
contentHash := sha256.Sum256(contents)
282
-
eTag := fmt.Sprintf("\"%x\"", contentHash)
283
-
284
-
// allow image, video, and text/plain files to be served directly
285
-
switch {
286
-
case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
287
-
if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
288
-
w.WriteHeader(http.StatusNotModified)
289
-
return
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
300
-
}
301
-
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)
362
-
if err != nil {
363
-
notFound(w)
364
-
return
365
-
}
366
-
367
-
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
368
-
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)
374
-
375
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
376
-
gr, err := git.Open(path, unescapedRef)
377
-
if err != nil {
378
-
notFound(w)
379
-
return
380
-
}
381
-
382
-
gw := gzip.NewWriter(w)
383
-
defer gw.Close()
384
-
385
-
prefix := fmt.Sprintf("%s-%s", name, safeRefFilename)
386
-
err = gr.WriteTar(gw, prefix)
387
-
if err != nil {
388
-
// once we start writing to the body we can't report error anymore
389
-
// so we are only left with printing the error.
390
-
l.Error("writing tar file", "error", err.Error())
391
-
return
392
-
}
393
-
394
-
err = gw.Flush()
395
-
if err != nil {
396
-
// once we start writing to the body we can't report error anymore
397
-
// so we are only left with printing the error.
398
-
l.Error("flushing?", "error", err.Error())
399
-
return
400
-
}
401
-
}
402
-
403
-
func (h *Handle) Log(w http.ResponseWriter, r *http.Request) {
404
-
ref := chi.URLParam(r, "ref")
405
-
ref, _ = url.PathUnescape(ref)
406
-
407
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
408
-
409
-
l := h.l.With("handler", "Log", "ref", ref, "path", path)
410
-
411
-
gr, err := git.Open(path, ref)
412
-
if err != nil {
413
-
notFound(w)
414
-
return
415
-
}
416
-
417
-
// Get page parameters
418
-
page := 1
419
-
pageSize := 30
420
-
421
-
if pageParam := r.URL.Query().Get("page"); pageParam != "" {
422
-
if p, err := strconv.Atoi(pageParam); err == nil && p > 0 {
423
-
page = p
424
-
}
425
-
}
426
-
427
-
if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" {
428
-
if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 {
429
-
pageSize = ps
430
-
}
431
-
}
432
-
433
-
// convert to offset/limit
434
-
offset := (page - 1) * pageSize
435
-
limit := pageSize
436
-
437
-
commits, err := gr.Commits(offset, limit)
438
-
if err != nil {
439
-
writeError(w, err.Error(), http.StatusInternalServerError)
440
-
l.Error("fetching commits", "error", err.Error())
441
-
return
442
-
}
443
-
444
-
total := len(commits)
445
-
446
-
resp := types.RepoLogResponse{
447
-
Commits: commits,
448
-
Ref: ref,
449
-
Description: getDescription(path),
450
-
Log: true,
451
-
Total: total,
452
-
Page: page,
453
-
PerPage: pageSize,
454
-
}
455
-
456
-
writeJSON(w, resp)
457
-
}
458
-
459
-
func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) {
460
-
ref := chi.URLParam(r, "ref")
461
-
ref, _ = url.PathUnescape(ref)
462
-
463
-
l := h.l.With("handler", "Diff", "ref", ref)
464
-
465
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
466
-
gr, err := git.Open(path, ref)
467
-
if err != nil {
468
-
notFound(w)
469
-
return
470
-
}
471
-
472
-
diff, err := gr.Diff()
473
-
if err != nil {
474
-
writeError(w, err.Error(), http.StatusInternalServerError)
475
-
l.Error("getting diff", "error", err.Error())
476
-
return
477
-
}
478
-
479
-
resp := types.RepoCommitResponse{
480
-
Ref: ref,
481
-
Diff: diff,
482
-
}
483
-
484
-
writeJSON(w, resp)
485
-
}
486
-
487
-
func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) {
488
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
489
-
l := h.l.With("handler", "Refs")
490
-
491
-
gr, err := git.Open(path, "")
492
-
if err != nil {
493
-
notFound(w)
494
-
return
495
-
}
496
-
497
-
tags, err := gr.Tags()
498
-
if err != nil {
499
-
// Non-fatal, we *should* have at least one branch to show.
500
-
l.Warn("getting tags", "error", err.Error())
501
-
}
502
-
503
-
rtags := []*types.TagReference{}
504
-
for _, tag := range tags {
505
-
var target *object.Tag
506
-
if tag.Target != plumbing.ZeroHash {
507
-
target = &tag
508
-
}
509
-
tr := types.TagReference{
510
-
Tag: target,
511
-
}
512
-
513
-
tr.Reference = types.Reference{
514
-
Name: tag.Name,
515
-
Hash: tag.Hash.String(),
516
-
}
517
-
518
-
if tag.Message != "" {
519
-
tr.Message = tag.Message
520
-
}
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())
609
-
return
610
-
}
611
-
612
-
data := make([]map[string]any, 0)
613
-
for _, key := range keys {
614
-
j := key.JSON()
615
-
data = append(data, j)
616
-
}
617
-
writeJSON(w, data)
618
-
return
619
-
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
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
732
-
}
733
-
734
-
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
735
-
defer cancel()
736
-
737
-
sizes, err := gr.AnalyzeLanguages(ctx)
738
-
if err != nil {
739
-
l.Error("failed to analyze languages", "error", err.Error())
740
-
writeError(w, err.Error(), http.StatusNoContent)
741
-
return
742
-
}
743
-
744
-
resp := types.RepoLanguageResponse{Languages: sizes}
745
-
746
-
writeJSON(w, resp)
747
-
}
748
-
749
-
// func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
750
-
// l := h.l.With("handler", "RepoForkSync")
751
-
//
752
-
// data := struct {
753
-
// Did string `json:"did"`
754
-
// Source string `json:"source"`
755
-
// Name string `json:"name,omitempty"`
756
-
// }{}
757
-
//
758
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
759
-
// writeError(w, "invalid request body", http.StatusBadRequest)
760
-
// return
761
-
// }
762
-
//
763
-
// did := data.Did
764
-
// source := data.Source
765
-
//
766
-
// if did == "" || source == "" {
767
-
// l.Error("invalid request body, empty did or name")
768
-
// w.WriteHeader(http.StatusBadRequest)
769
-
// return
770
-
// }
771
-
//
772
-
// var name string
773
-
// if data.Name != "" {
774
-
// name = data.Name
775
-
// } else {
776
-
// name = filepath.Base(source)
777
-
// }
778
-
//
779
-
// branch := chi.URLParam(r, "branch")
780
-
// branch, _ = url.PathUnescape(branch)
781
-
//
782
-
// relativeRepoPath := filepath.Join(did, name)
783
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
784
-
//
785
-
// gr, err := git.Open(repoPath, branch)
786
-
// if err != nil {
787
-
// log.Println(err)
788
-
// notFound(w)
789
-
// return
790
-
// }
791
-
//
792
-
// err = gr.Sync()
793
-
// if err != nil {
794
-
// l.Error("error syncing repo fork", "error", err.Error())
795
-
// writeError(w, err.Error(), http.StatusInternalServerError)
796
-
// return
797
-
// }
798
-
//
799
-
// w.WriteHeader(http.StatusNoContent)
800
-
// }
801
-
802
-
// func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) {
803
-
// l := h.l.With("handler", "RepoFork")
804
-
//
805
-
// data := struct {
806
-
// Did string `json:"did"`
807
-
// Source string `json:"source"`
808
-
// Name string `json:"name,omitempty"`
809
-
// }{}
810
-
//
811
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
812
-
// writeError(w, "invalid request body", http.StatusBadRequest)
813
-
// return
814
-
// }
815
-
//
816
-
// did := data.Did
817
-
// source := data.Source
818
-
//
819
-
// if did == "" || source == "" {
820
-
// l.Error("invalid request body, empty did or name")
821
-
// w.WriteHeader(http.StatusBadRequest)
822
-
// return
823
-
// }
824
-
//
825
-
// var name string
826
-
// if data.Name != "" {
827
-
// name = data.Name
828
-
// } else {
829
-
// name = filepath.Base(source)
830
-
// }
831
-
//
832
-
// relativeRepoPath := filepath.Join(did, name)
833
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
834
-
//
835
-
// err := git.Fork(repoPath, source)
836
-
// if err != nil {
837
-
// l.Error("forking repo", "error", err.Error())
838
-
// writeError(w, err.Error(), http.StatusInternalServerError)
839
-
// return
840
-
// }
841
-
//
842
-
// // add perms for this user to access the repo
843
-
// err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
844
-
// if err != nil {
845
-
// l.Error("adding repo permissions", "error", err.Error())
846
-
// writeError(w, err.Error(), http.StatusInternalServerError)
847
-
// return
848
-
// }
849
-
//
850
-
// hook.SetupRepo(
851
-
// hook.Config(
852
-
// hook.WithScanPath(h.c.Repo.ScanPath),
853
-
// hook.WithInternalApi(h.c.Server.InternalListenAddr),
854
-
// ),
855
-
// repoPath,
856
-
// )
857
-
//
858
-
// w.WriteHeader(http.StatusNoContent)
859
-
// }
860
-
861
-
// func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
862
-
// l := h.l.With("handler", "RemoveRepo")
863
-
//
864
-
// data := struct {
865
-
// Did string `json:"did"`
866
-
// Name string `json:"name"`
867
-
// }{}
868
-
//
869
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
870
-
// writeError(w, "invalid request body", http.StatusBadRequest)
871
-
// return
872
-
// }
873
-
//
874
-
// did := data.Did
875
-
// name := data.Name
876
-
//
877
-
// if did == "" || name == "" {
878
-
// l.Error("invalid request body, empty did or name")
879
-
// w.WriteHeader(http.StatusBadRequest)
880
-
// return
881
-
// }
882
-
//
883
-
// relativeRepoPath := filepath.Join(did, name)
884
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
885
-
// err := os.RemoveAll(repoPath)
886
-
// if err != nil {
887
-
// l.Error("removing repo", "error", err.Error())
888
-
// writeError(w, err.Error(), http.StatusInternalServerError)
889
-
// return
890
-
// }
891
-
//
892
-
// w.WriteHeader(http.StatusNoContent)
893
-
//
894
-
// }
895
-
896
-
// func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) {
897
-
// path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
898
-
//
899
-
// data := types.MergeRequest{}
900
-
//
901
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
902
-
// writeError(w, err.Error(), http.StatusBadRequest)
903
-
// h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err)
904
-
// return
905
-
// }
906
-
//
907
-
// mo := &git.MergeOptions{
908
-
// AuthorName: data.AuthorName,
909
-
// AuthorEmail: data.AuthorEmail,
910
-
// CommitBody: data.CommitBody,
911
-
// CommitMessage: data.CommitMessage,
912
-
// }
913
-
//
914
-
// patch := data.Patch
915
-
// branch := data.Branch
916
-
// gr, err := git.Open(path, branch)
917
-
// if err != nil {
918
-
// notFound(w)
919
-
// return
920
-
// }
921
-
//
922
-
// mo.FormatPatch = patchutil.IsFormatPatch(patch)
923
-
//
924
-
// if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil {
925
-
// var mergeErr *git.ErrMerge
926
-
// if errors.As(err, &mergeErr) {
927
-
// conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
928
-
// for i, conflict := range mergeErr.Conflicts {
929
-
// conflicts[i] = types.ConflictInfo{
930
-
// Filename: conflict.Filename,
931
-
// Reason: conflict.Reason,
932
-
// }
933
-
// }
934
-
// response := types.MergeCheckResponse{
935
-
// IsConflicted: true,
936
-
// Conflicts: conflicts,
937
-
// Message: mergeErr.Message,
938
-
// }
939
-
// writeConflict(w, response)
940
-
// h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr)
941
-
// } else {
942
-
// writeError(w, err.Error(), http.StatusBadRequest)
943
-
// h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error())
944
-
// }
945
-
// return
946
-
// }
947
-
//
948
-
// w.WriteHeader(http.StatusOK)
949
-
// }
950
-
951
-
// func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) {
952
-
// path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
953
-
//
954
-
// var data struct {
955
-
// Patch string `json:"patch"`
956
-
// Branch string `json:"branch"`
957
-
// }
958
-
//
959
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
960
-
// writeError(w, err.Error(), http.StatusBadRequest)
961
-
// h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err)
962
-
// return
963
-
// }
964
-
//
965
-
// patch := data.Patch
966
-
// branch := data.Branch
967
-
// gr, err := git.Open(path, branch)
968
-
// if err != nil {
969
-
// notFound(w)
970
-
// return
971
-
// }
972
-
//
973
-
// err = gr.MergeCheck([]byte(patch), branch)
974
-
// if err == nil {
975
-
// response := types.MergeCheckResponse{
976
-
// IsConflicted: false,
977
-
// }
978
-
// writeJSON(w, response)
979
-
// return
980
-
// }
981
-
//
982
-
// var mergeErr *git.ErrMerge
983
-
// if errors.As(err, &mergeErr) {
984
-
// conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
985
-
// for i, conflict := range mergeErr.Conflicts {
986
-
// conflicts[i] = types.ConflictInfo{
987
-
// Filename: conflict.Filename,
988
-
// Reason: conflict.Reason,
989
-
// }
990
-
// }
991
-
// response := types.MergeCheckResponse{
992
-
// IsConflicted: true,
993
-
// Conflicts: conflicts,
994
-
// Message: mergeErr.Message,
995
-
// }
996
-
// writeConflict(w, response)
997
-
// h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error())
998
-
// return
999
-
// }
1000
-
// writeError(w, err.Error(), http.StatusInternalServerError)
1001
-
// h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error())
1002
-
// }
1003
-
1004
-
func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) {
1005
-
rev1 := chi.URLParam(r, "rev1")
1006
-
rev1, _ = url.PathUnescape(rev1)
1007
-
1008
-
rev2 := chi.URLParam(r, "rev2")
1009
-
rev2, _ = url.PathUnescape(rev2)
1010
-
1011
-
l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2)
1012
-
1013
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1014
-
gr, err := git.PlainOpen(path)
1015
-
if err != nil {
1016
-
notFound(w)
1017
-
return
1018
-
}
1019
-
1020
-
commit1, err := gr.ResolveRevision(rev1)
1021
-
if err != nil {
1022
-
l.Error("error resolving revision 1", "msg", err.Error())
1023
-
writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest)
1024
-
return
1025
-
}
1026
-
1027
-
commit2, err := gr.ResolveRevision(rev2)
1028
-
if err != nil {
1029
-
l.Error("error resolving revision 2", "msg", err.Error())
1030
-
writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest)
1031
-
return
1032
-
}
1033
-
1034
-
rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2)
1035
-
if err != nil {
1036
-
l.Error("error comparing revisions", "msg", err.Error())
1037
-
writeError(w, "error comparing revisions", http.StatusBadRequest)
1038
-
return
1039
-
}
1040
-
1041
-
writeJSON(w, types.RepoFormatPatchResponse{
1042
-
Rev1: commit1.Hash.String(),
1043
-
Rev2: commit2.Hash.String(),
1044
-
FormatPatch: formatPatch,
1045
-
Patch: rawPatch,
1046
-
})
1047
-
}
1048
-
1049
-
func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) {
1050
-
l := h.l.With("handler", "DefaultBranch")
1051
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1052
-
1053
-
gr, err := git.Open(path, "")
1054
-
if err != nil {
1055
-
notFound(w)
1056
-
return
1057
-
}
1058
-
1059
-
branch, err := gr.FindMainBranch()
1060
-
if err != nil {
1061
-
writeError(w, err.Error(), http.StatusInternalServerError)
1062
-
l.Error("getting default branch", "error", err.Error())
1063
-
return
1064
-
}
1065
-
1066
-
writeJSON(w, types.RepoDefaultBranchResponse{
1067
-
Branch: branch,
1068
-
})
1069
-
}
+11
-6
knotserver/ingester.go
+11
-6
knotserver/ingester.go
···
24
24
"tangled.sh/tangled.sh/core/workflow"
25
25
)
26
26
27
-
func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error {
27
+
func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error {
28
28
l := log.FromContext(ctx)
29
29
raw := json.RawMessage(event.Commit.Record)
30
30
did := event.Did
···
46
46
return nil
47
47
}
48
48
49
-
func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error {
49
+
func (h *Knot) processKnotMember(ctx context.Context, event *models.Event) error {
50
50
l := log.FromContext(ctx)
51
51
raw := json.RawMessage(event.Commit.Record)
52
52
did := event.Did
···
86
86
return nil
87
87
}
88
88
89
-
func (h *Handle) processPull(ctx context.Context, event *models.Event) error {
89
+
func (h *Knot) processPull(ctx context.Context, event *models.Event) error {
90
90
raw := json.RawMessage(event.Commit.Record)
91
91
did := event.Did
92
92
···
98
98
l := log.FromContext(ctx)
99
99
l = l.With("handler", "processPull")
100
100
l = l.With("did", did)
101
+
102
+
if record.Target == nil {
103
+
return fmt.Errorf("ignoring pull record: target repo is nil")
104
+
}
105
+
101
106
l = l.With("target_repo", record.Target.Repo)
102
107
l = l.With("target_branch", record.Target.Branch)
103
108
···
214
219
}
215
220
216
221
// duplicated from add collaborator
217
-
func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error {
222
+
func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error {
218
223
raw := json.RawMessage(event.Commit.Record)
219
224
did := event.Did
220
225
···
275
280
return h.fetchAndAddKeys(ctx, subjectId.DID.String())
276
281
}
277
282
278
-
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
283
+
func (h *Knot) fetchAndAddKeys(ctx context.Context, did string) error {
279
284
l := log.FromContext(ctx)
280
285
281
286
keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did)
···
318
323
return nil
319
324
}
320
325
321
-
func (h *Handle) processMessages(ctx context.Context, event *models.Event) error {
326
+
func (h *Knot) processMessages(ctx context.Context, event *models.Event) error {
322
327
if event.Kind != models.EventKindCommit {
323
328
return nil
324
329
}
+152
knotserver/router.go
+152
knotserver/router.go
···
1
+
package knotserver
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"log/slog"
7
+
"net/http"
8
+
9
+
"github.com/go-chi/chi/v5"
10
+
"tangled.sh/tangled.sh/core/idresolver"
11
+
"tangled.sh/tangled.sh/core/jetstream"
12
+
"tangled.sh/tangled.sh/core/knotserver/config"
13
+
"tangled.sh/tangled.sh/core/knotserver/db"
14
+
"tangled.sh/tangled.sh/core/knotserver/xrpc"
15
+
tlog "tangled.sh/tangled.sh/core/log"
16
+
"tangled.sh/tangled.sh/core/notifier"
17
+
"tangled.sh/tangled.sh/core/rbac"
18
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
19
+
)
20
+
21
+
type Knot struct {
22
+
c *config.Config
23
+
db *db.DB
24
+
jc *jetstream.JetstreamClient
25
+
e *rbac.Enforcer
26
+
l *slog.Logger
27
+
n *notifier.Notifier
28
+
resolver *idresolver.Resolver
29
+
}
30
+
31
+
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) {
32
+
r := chi.NewRouter()
33
+
34
+
h := Knot{
35
+
c: c,
36
+
db: db,
37
+
e: e,
38
+
l: l,
39
+
jc: jc,
40
+
n: n,
41
+
resolver: idresolver.DefaultResolver(),
42
+
}
43
+
44
+
err := e.AddKnot(rbac.ThisServer)
45
+
if err != nil {
46
+
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
47
+
}
48
+
49
+
// configure owner
50
+
if err = h.configureOwner(); err != nil {
51
+
return nil, err
52
+
}
53
+
h.l.Info("owner set", "did", h.c.Server.Owner)
54
+
h.jc.AddDid(h.c.Server.Owner)
55
+
56
+
// configure known-dids in jetstream consumer
57
+
dids, err := h.db.GetAllDids()
58
+
if err != nil {
59
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
60
+
}
61
+
for _, d := range dids {
62
+
jc.AddDid(d)
63
+
}
64
+
65
+
err = h.jc.StartJetstream(ctx, h.processMessages)
66
+
if err != nil {
67
+
return nil, fmt.Errorf("failed to start jetstream: %w", err)
68
+
}
69
+
70
+
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
71
+
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
72
+
})
73
+
74
+
r.Route("/{did}", func(r chi.Router) {
75
+
r.Route("/{name}", func(r chi.Router) {
76
+
// routes for git operations
77
+
r.Get("/info/refs", h.InfoRefs)
78
+
r.Post("/git-upload-pack", h.UploadPack)
79
+
r.Post("/git-receive-pack", h.ReceivePack)
80
+
})
81
+
})
82
+
83
+
// xrpc apis
84
+
r.Mount("/xrpc", h.XrpcRouter())
85
+
86
+
// Socket that streams git oplogs
87
+
r.Get("/events", h.Events)
88
+
89
+
return r, nil
90
+
}
91
+
92
+
func (h *Knot) XrpcRouter() http.Handler {
93
+
logger := tlog.New("knots")
94
+
95
+
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
96
+
97
+
xrpc := &xrpc.Xrpc{
98
+
Config: h.c,
99
+
Db: h.db,
100
+
Ingester: h.jc,
101
+
Enforcer: h.e,
102
+
Logger: logger,
103
+
Notifier: h.n,
104
+
Resolver: h.resolver,
105
+
ServiceAuth: serviceAuth,
106
+
}
107
+
return xrpc.Router()
108
+
}
109
+
110
+
func (h *Knot) configureOwner() error {
111
+
cfgOwner := h.c.Server.Owner
112
+
113
+
rbacDomain := "thisserver"
114
+
115
+
existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain)
116
+
if err != nil {
117
+
return err
118
+
}
119
+
120
+
switch len(existing) {
121
+
case 0:
122
+
// no owner configured, continue
123
+
case 1:
124
+
// find existing owner
125
+
existingOwner := existing[0]
126
+
127
+
// no ownership change, this is okay
128
+
if existingOwner == h.c.Server.Owner {
129
+
break
130
+
}
131
+
132
+
// remove existing owner
133
+
if err = h.db.RemoveDid(existingOwner); err != nil {
134
+
return err
135
+
}
136
+
if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil {
137
+
return err
138
+
}
139
+
140
+
default:
141
+
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath)
142
+
}
143
+
144
+
if err = h.db.AddDid(cfgOwner); err != nil {
145
+
return fmt.Errorf("failed to add owner to DB: %w", err)
146
+
}
147
+
if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil {
148
+
return fmt.Errorf("failed to add owner to RBAC: %w", err)
149
+
}
150
+
151
+
return nil
152
+
}
-217
knotserver/routes.go
-217
knotserver/routes.go
···
1
-
package knotserver
2
-
3
-
import (
4
-
"context"
5
-
"fmt"
6
-
"log/slog"
7
-
"net/http"
8
-
"runtime/debug"
9
-
10
-
"github.com/go-chi/chi/v5"
11
-
"tangled.sh/tangled.sh/core/idresolver"
12
-
"tangled.sh/tangled.sh/core/jetstream"
13
-
"tangled.sh/tangled.sh/core/knotserver/config"
14
-
"tangled.sh/tangled.sh/core/knotserver/db"
15
-
"tangled.sh/tangled.sh/core/knotserver/xrpc"
16
-
tlog "tangled.sh/tangled.sh/core/log"
17
-
"tangled.sh/tangled.sh/core/notifier"
18
-
"tangled.sh/tangled.sh/core/rbac"
19
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
20
-
)
21
-
22
-
type Handle struct {
23
-
c *config.Config
24
-
db *db.DB
25
-
jc *jetstream.JetstreamClient
26
-
e *rbac.Enforcer
27
-
l *slog.Logger
28
-
n *notifier.Notifier
29
-
resolver *idresolver.Resolver
30
-
}
31
-
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()
34
-
35
-
h := Handle{
36
-
c: c,
37
-
db: db,
38
-
e: e,
39
-
l: l,
40
-
jc: jc,
41
-
n: n,
42
-
resolver: idresolver.DefaultResolver(),
43
-
}
44
-
45
-
err := e.AddKnot(rbac.ThisServer)
46
-
if err != nil {
47
-
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
48
-
}
49
-
50
-
// configure owner
51
-
if err = h.configureOwner(); err != nil {
52
-
return nil, err
53
-
}
54
-
h.l.Info("owner set", "did", h.c.Server.Owner)
55
-
h.jc.AddDid(h.c.Server.Owner)
56
-
57
-
// configure known-dids in jetstream consumer
58
-
dids, err := h.db.GetAllDids()
59
-
if err != nil {
60
-
return nil, fmt.Errorf("failed to get all dids: %w", err)
61
-
}
62
-
for _, d := range dids {
63
-
jc.AddDid(d)
64
-
}
65
-
66
-
err = h.jc.StartJetstream(ctx, h.processMessages)
67
-
if err != nil {
68
-
return nil, fmt.Errorf("failed to start jetstream: %w", err)
69
-
}
70
-
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) {
80
-
81
-
r.Route("/languages", func(r chi.Router) {
82
-
r.Get("/", h.RepoLanguages)
83
-
r.Get("/{ref}", h.RepoLanguages)
84
-
})
85
-
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
91
-
92
-
r.Route("/tree/{ref}", func(r chi.Router) {
93
-
r.Get("/", h.RepoIndex)
94
-
r.Get("/*", h.RepoTree)
95
-
})
96
-
97
-
r.Route("/blob/{ref}", func(r chi.Router) {
98
-
r.Get("/*", h.Blob)
99
-
})
100
-
101
-
r.Route("/raw/{ref}", func(r chi.Router) {
102
-
r.Get("/*", h.BlobRaw)
103
-
})
104
-
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
-
})
116
-
117
-
// xrpc apis
118
-
r.Mount("/xrpc", h.XrpcRouter())
119
-
120
-
// Socket that streams git oplogs
121
-
r.Get("/events", h.Events)
122
-
123
-
// All public keys on the knot.
124
-
r.Get("/keys", h.Keys)
125
-
126
-
return r, nil
127
-
}
128
-
129
-
func (h *Handle) XrpcRouter() http.Handler {
130
-
logger := tlog.New("knots")
131
-
132
-
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
133
-
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,
143
-
}
144
-
return xrpc.Router()
145
-
}
146
-
147
-
// version is set during build time.
148
-
var version string
149
-
150
-
func (h *Handle) Version(w http.ResponseWriter, r *http.Request) {
151
-
if version == "" {
152
-
info, ok := debug.ReadBuildInfo()
153
-
if !ok {
154
-
http.Error(w, "failed to read build info", http.StatusInternalServerError)
155
-
return
156
-
}
157
-
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
-
}
164
-
}
165
-
166
-
if modVer == "" {
167
-
version = "unknown"
168
-
}
169
-
}
170
-
171
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
172
-
fmt.Fprintf(w, "knotserver/%s", version)
173
-
}
174
-
175
-
func (h *Handle) configureOwner() error {
176
-
cfgOwner := h.c.Server.Owner
177
-
178
-
rbacDomain := "thisserver"
179
-
180
-
existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain)
181
-
if err != nil {
182
-
return err
183
-
}
184
-
185
-
switch len(existing) {
186
-
case 0:
187
-
// no owner configured, continue
188
-
case 1:
189
-
// find existing owner
190
-
existingOwner := existing[0]
191
-
192
-
// no ownership change, this is okay
193
-
if existingOwner == h.c.Server.Owner {
194
-
break
195
-
}
196
-
197
-
// remove existing owner
198
-
if err = h.db.RemoveDid(existingOwner); err != nil {
199
-
return err
200
-
}
201
-
if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil {
202
-
return err
203
-
}
204
-
205
-
default:
206
-
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath)
207
-
}
208
-
209
-
if err = h.db.AddDid(cfgOwner); err != nil {
210
-
return fmt.Errorf("failed to add owner to DB: %w", err)
211
-
}
212
-
if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil {
213
-
return fmt.Errorf("failed to add owner to RBAC: %w", err)
214
-
}
215
-
216
-
return nil
217
-
}
+16
-13
knotserver/server.go
+16
-13
knotserver/server.go
···
22
22
Usage: "run a knot server",
23
23
Action: Run,
24
24
Description: `
25
-
Environment variables:
26
-
KNOT_SERVER_SECRET (required)
27
-
KNOT_SERVER_HOSTNAME (required)
28
-
KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555)
29
-
KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444)
30
-
KNOT_SERVER_DB_PATH (default: knotserver.db)
31
-
KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe)
32
-
KNOT_SERVER_DEV (default: false)
33
-
KNOT_REPO_SCAN_PATH (default: /home/git)
34
-
KNOT_REPO_README (comma-separated list)
35
-
KNOT_REPO_MAIN_BRANCH (default: main)
36
-
APPVIEW_ENDPOINT (default: https://tangled.sh)
37
-
`,
25
+
Environment variables:
26
+
KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555)
27
+
KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444)
28
+
KNOT_SERVER_DB_PATH (default: knotserver.db)
29
+
KNOT_SERVER_HOSTNAME (required)
30
+
KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe)
31
+
KNOT_SERVER_OWNER (required)
32
+
KNOT_SERVER_LOG_DIDS (default: true)
33
+
KNOT_SERVER_DEV (default: false)
34
+
KNOT_REPO_SCAN_PATH (default: /home/git)
35
+
KNOT_REPO_README (comma-separated list)
36
+
KNOT_REPO_MAIN_BRANCH (default: main)
37
+
KNOT_GIT_USER_NAME (default: Tangled)
38
+
KNOT_GIT_USER_EMAIL (default: noreply@tangled.sh)
39
+
APPVIEW_ENDPOINT (default: https://tangled.sh)
40
+
`,
38
41
}
39
42
}
40
43
+58
knotserver/xrpc/list_keys.go
+58
knotserver/xrpc/list_keys.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
"strconv"
7
+
8
+
"tangled.sh/tangled.sh/core/api/tangled"
9
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
10
+
)
11
+
12
+
func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {
13
+
cursor := r.URL.Query().Get("cursor")
14
+
15
+
limit := 100 // default
16
+
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
17
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
18
+
limit = l
19
+
}
20
+
}
21
+
22
+
keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor)
23
+
if err != nil {
24
+
x.Logger.Error("failed to get public keys", "error", err)
25
+
writeError(w, xrpcerr.NewXrpcError(
26
+
xrpcerr.WithTag("InternalServerError"),
27
+
xrpcerr.WithMessage("failed to retrieve public keys"),
28
+
), http.StatusInternalServerError)
29
+
return
30
+
}
31
+
32
+
publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys))
33
+
for _, key := range keys {
34
+
publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{
35
+
Did: key.Did,
36
+
Key: key.Key,
37
+
CreatedAt: key.CreatedAt,
38
+
})
39
+
}
40
+
41
+
response := tangled.KnotListKeys_Output{
42
+
Keys: publicKeys,
43
+
}
44
+
45
+
if nextCursor != "" {
46
+
response.Cursor = &nextCursor
47
+
}
48
+
49
+
w.Header().Set("Content-Type", "application/json")
50
+
if err := json.NewEncoder(w).Encode(response); err != nil {
51
+
x.Logger.Error("failed to encode response", "error", err)
52
+
writeError(w, xrpcerr.NewXrpcError(
53
+
xrpcerr.WithTag("InternalServerError"),
54
+
xrpcerr.WithMessage("failed to encode response"),
55
+
), http.StatusInternalServerError)
56
+
return
57
+
}
58
+
}
+3
-1
knotserver/xrpc/merge.go
+3
-1
knotserver/xrpc/merge.go
···
67
67
return
68
68
}
69
69
70
-
mo := &git.MergeOptions{}
70
+
mo := git.MergeOptions{}
71
71
if data.AuthorName != nil {
72
72
mo.AuthorName = *data.AuthorName
73
73
}
···
81
81
mo.CommitMessage = *data.CommitMessage
82
82
}
83
83
84
+
mo.CommitterName = x.Config.Git.UserName
85
+
mo.CommitterEmail = x.Config.Git.UserEmail
84
86
mo.FormatPatch = patchutil.IsFormatPatch(data.Patch)
85
87
86
88
err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
+31
knotserver/xrpc/owner.go
+31
knotserver/xrpc/owner.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
7
+
"tangled.sh/tangled.sh/core/api/tangled"
8
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
9
+
)
10
+
11
+
func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
12
+
owner := x.Config.Server.Owner
13
+
if owner == "" {
14
+
writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError)
15
+
return
16
+
}
17
+
18
+
response := tangled.Owner_Output{
19
+
Owner: owner,
20
+
}
21
+
22
+
w.Header().Set("Content-Type", "application/json")
23
+
if err := json.NewEncoder(w).Encode(response); err != nil {
24
+
x.Logger.Error("failed to encode response", "error", err)
25
+
writeError(w, xrpcerr.NewXrpcError(
26
+
xrpcerr.WithTag("InternalServerError"),
27
+
xrpcerr.WithMessage("failed to encode response"),
28
+
), http.StatusInternalServerError)
29
+
return
30
+
}
31
+
}
+80
knotserver/xrpc/repo_archive.go
+80
knotserver/xrpc/repo_archive.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"compress/gzip"
5
+
"fmt"
6
+
"net/http"
7
+
"strings"
8
+
9
+
"github.com/go-git/go-git/v5/plumbing"
10
+
11
+
"tangled.sh/tangled.sh/core/knotserver/git"
12
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
13
+
)
14
+
15
+
func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {
16
+
repo, repoPath, unescapedRef, err := x.parseStandardParams(r)
17
+
if err != nil {
18
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
19
+
return
20
+
}
21
+
22
+
format := r.URL.Query().Get("format")
23
+
if format == "" {
24
+
format = "tar.gz" // default
25
+
}
26
+
27
+
prefix := r.URL.Query().Get("prefix")
28
+
29
+
if format != "tar.gz" {
30
+
writeError(w, xrpcerr.NewXrpcError(
31
+
xrpcerr.WithTag("InvalidRequest"),
32
+
xrpcerr.WithMessage("only tar.gz format is supported"),
33
+
), http.StatusBadRequest)
34
+
return
35
+
}
36
+
37
+
gr, err := git.Open(repoPath, unescapedRef)
38
+
if err != nil {
39
+
writeError(w, xrpcerr.NewXrpcError(
40
+
xrpcerr.WithTag("RefNotFound"),
41
+
xrpcerr.WithMessage("repository or ref not found"),
42
+
), http.StatusNotFound)
43
+
return
44
+
}
45
+
46
+
repoParts := strings.Split(repo, "/")
47
+
repoName := repoParts[len(repoParts)-1]
48
+
49
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
50
+
51
+
var archivePrefix string
52
+
if prefix != "" {
53
+
archivePrefix = prefix
54
+
} else {
55
+
archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename)
56
+
}
57
+
58
+
filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename)
59
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
60
+
w.Header().Set("Content-Type", "application/gzip")
61
+
62
+
gw := gzip.NewWriter(w)
63
+
defer gw.Close()
64
+
65
+
err = gr.WriteTar(gw, archivePrefix)
66
+
if err != nil {
67
+
// once we start writing to the body we can't report error anymore
68
+
// so we are only left with logging the error
69
+
x.Logger.Error("writing tar file", "error", err.Error())
70
+
return
71
+
}
72
+
73
+
err = gw.Flush()
74
+
if err != nil {
75
+
// once we start writing to the body we can't report error anymore
76
+
// so we are only left with logging the error
77
+
x.Logger.Error("flushing", "error", err.Error())
78
+
return
79
+
}
80
+
}
+151
knotserver/xrpc/repo_blob.go
+151
knotserver/xrpc/repo_blob.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"crypto/sha256"
5
+
"encoding/base64"
6
+
"encoding/json"
7
+
"fmt"
8
+
"net/http"
9
+
"path/filepath"
10
+
"slices"
11
+
"strings"
12
+
13
+
"tangled.sh/tangled.sh/core/api/tangled"
14
+
"tangled.sh/tangled.sh/core/knotserver/git"
15
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
+
)
17
+
18
+
func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
19
+
_, repoPath, ref, err := x.parseStandardParams(r)
20
+
if err != nil {
21
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
22
+
return
23
+
}
24
+
25
+
treePath := r.URL.Query().Get("path")
26
+
if treePath == "" {
27
+
writeError(w, xrpcerr.NewXrpcError(
28
+
xrpcerr.WithTag("InvalidRequest"),
29
+
xrpcerr.WithMessage("missing path parameter"),
30
+
), http.StatusBadRequest)
31
+
return
32
+
}
33
+
34
+
raw := r.URL.Query().Get("raw") == "true"
35
+
36
+
gr, err := git.Open(repoPath, ref)
37
+
if err != nil {
38
+
writeError(w, xrpcerr.NewXrpcError(
39
+
xrpcerr.WithTag("RefNotFound"),
40
+
xrpcerr.WithMessage("repository or ref not found"),
41
+
), http.StatusNotFound)
42
+
return
43
+
}
44
+
45
+
contents, err := gr.RawContent(treePath)
46
+
if err != nil {
47
+
x.Logger.Error("file content", "error", err.Error())
48
+
writeError(w, xrpcerr.NewXrpcError(
49
+
xrpcerr.WithTag("FileNotFound"),
50
+
xrpcerr.WithMessage("file not found at the specified path"),
51
+
), http.StatusNotFound)
52
+
return
53
+
}
54
+
55
+
mimeType := http.DetectContentType(contents)
56
+
57
+
if filepath.Ext(treePath) == ".svg" {
58
+
mimeType = "image/svg+xml"
59
+
}
60
+
61
+
if raw {
62
+
contentHash := sha256.Sum256(contents)
63
+
eTag := fmt.Sprintf("\"%x\"", contentHash)
64
+
65
+
switch {
66
+
case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
67
+
if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
68
+
w.WriteHeader(http.StatusNotModified)
69
+
return
70
+
}
71
+
w.Header().Set("ETag", eTag)
72
+
w.Header().Set("Content-Type", mimeType)
73
+
74
+
case strings.HasPrefix(mimeType, "text/"):
75
+
w.Header().Set("Cache-Control", "public, no-cache")
76
+
// serve all text content as text/plain
77
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
78
+
79
+
case isTextualMimeType(mimeType):
80
+
// handle textual application types (json, xml, etc.) as text/plain
81
+
w.Header().Set("Cache-Control", "public, no-cache")
82
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
83
+
84
+
default:
85
+
x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType)
86
+
writeError(w, xrpcerr.NewXrpcError(
87
+
xrpcerr.WithTag("InvalidRequest"),
88
+
xrpcerr.WithMessage("only image, video, and text files can be accessed directly"),
89
+
), http.StatusForbidden)
90
+
return
91
+
}
92
+
w.Write(contents)
93
+
return
94
+
}
95
+
96
+
isTextual := func(mt string) bool {
97
+
return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt)
98
+
}
99
+
100
+
var content string
101
+
var encoding string
102
+
103
+
isBinary := !isTextual(mimeType)
104
+
105
+
if isBinary {
106
+
content = base64.StdEncoding.EncodeToString(contents)
107
+
encoding = "base64"
108
+
} else {
109
+
content = string(contents)
110
+
encoding = "utf-8"
111
+
}
112
+
113
+
response := tangled.RepoBlob_Output{
114
+
Ref: ref,
115
+
Path: treePath,
116
+
Content: content,
117
+
Encoding: &encoding,
118
+
Size: &[]int64{int64(len(contents))}[0],
119
+
IsBinary: &isBinary,
120
+
}
121
+
122
+
if mimeType != "" {
123
+
response.MimeType = &mimeType
124
+
}
125
+
126
+
w.Header().Set("Content-Type", "application/json")
127
+
if err := json.NewEncoder(w).Encode(response); err != nil {
128
+
x.Logger.Error("failed to encode response", "error", err)
129
+
writeError(w, xrpcerr.NewXrpcError(
130
+
xrpcerr.WithTag("InternalServerError"),
131
+
xrpcerr.WithMessage("failed to encode response"),
132
+
), http.StatusInternalServerError)
133
+
return
134
+
}
135
+
}
136
+
137
+
// isTextualMimeType returns true if the MIME type represents textual content
138
+
// that should be served as text/plain for security reasons
139
+
func isTextualMimeType(mimeType string) bool {
140
+
textualTypes := []string{
141
+
"application/json",
142
+
"application/xml",
143
+
"application/yaml",
144
+
"application/x-yaml",
145
+
"application/toml",
146
+
"application/javascript",
147
+
"application/ecmascript",
148
+
}
149
+
150
+
return slices.Contains(textualTypes, mimeType)
151
+
}
+96
knotserver/xrpc/repo_branch.go
+96
knotserver/xrpc/repo_branch.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
"net/url"
7
+
8
+
"tangled.sh/tangled.sh/core/api/tangled"
9
+
"tangled.sh/tangled.sh/core/knotserver/git"
10
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
)
12
+
13
+
func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
14
+
repo := r.URL.Query().Get("repo")
15
+
repoPath, err := x.parseRepoParam(repo)
16
+
if err != nil {
17
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
18
+
return
19
+
}
20
+
21
+
name := r.URL.Query().Get("name")
22
+
if name == "" {
23
+
writeError(w, xrpcerr.NewXrpcError(
24
+
xrpcerr.WithTag("InvalidRequest"),
25
+
xrpcerr.WithMessage("missing name parameter"),
26
+
), http.StatusBadRequest)
27
+
return
28
+
}
29
+
30
+
branchName, _ := url.PathUnescape(name)
31
+
32
+
gr, err := git.PlainOpen(repoPath)
33
+
if err != nil {
34
+
writeError(w, xrpcerr.NewXrpcError(
35
+
xrpcerr.WithTag("RepoNotFound"),
36
+
xrpcerr.WithMessage("repository not found"),
37
+
), http.StatusNotFound)
38
+
return
39
+
}
40
+
41
+
ref, err := gr.Branch(branchName)
42
+
if err != nil {
43
+
x.Logger.Error("getting branch", "error", err.Error())
44
+
writeError(w, xrpcerr.NewXrpcError(
45
+
xrpcerr.WithTag("BranchNotFound"),
46
+
xrpcerr.WithMessage("branch not found"),
47
+
), http.StatusNotFound)
48
+
return
49
+
}
50
+
51
+
commit, err := gr.Commit(ref.Hash())
52
+
if err != nil {
53
+
x.Logger.Error("getting commit object", "error", err.Error())
54
+
writeError(w, xrpcerr.NewXrpcError(
55
+
xrpcerr.WithTag("BranchNotFound"),
56
+
xrpcerr.WithMessage("failed to get commit object"),
57
+
), http.StatusInternalServerError)
58
+
return
59
+
}
60
+
61
+
defaultBranch, err := gr.FindMainBranch()
62
+
isDefault := false
63
+
if err != nil {
64
+
x.Logger.Error("getting default branch", "error", err.Error())
65
+
} else if defaultBranch == branchName {
66
+
isDefault = true
67
+
}
68
+
69
+
response := tangled.RepoBranch_Output{
70
+
Name: ref.Name().Short(),
71
+
Hash: ref.Hash().String(),
72
+
ShortHash: &[]string{ref.Hash().String()[:7]}[0],
73
+
When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"),
74
+
IsDefault: &isDefault,
75
+
}
76
+
77
+
if commit.Message != "" {
78
+
response.Message = &commit.Message
79
+
}
80
+
81
+
response.Author = &tangled.RepoBranch_Signature{
82
+
Name: commit.Author.Name,
83
+
Email: commit.Author.Email,
84
+
When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"),
85
+
}
86
+
87
+
w.Header().Set("Content-Type", "application/json")
88
+
if err := json.NewEncoder(w).Encode(response); err != nil {
89
+
x.Logger.Error("failed to encode response", "error", err)
90
+
writeError(w, xrpcerr.NewXrpcError(
91
+
xrpcerr.WithTag("InternalServerError"),
92
+
xrpcerr.WithMessage("failed to encode response"),
93
+
), http.StatusInternalServerError)
94
+
return
95
+
}
96
+
}
+72
knotserver/xrpc/repo_branches.go
+72
knotserver/xrpc/repo_branches.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
"strconv"
7
+
8
+
"tangled.sh/tangled.sh/core/knotserver/git"
9
+
"tangled.sh/tangled.sh/core/types"
10
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
)
12
+
13
+
func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) {
14
+
repo := r.URL.Query().Get("repo")
15
+
repoPath, err := x.parseRepoParam(repo)
16
+
if err != nil {
17
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
18
+
return
19
+
}
20
+
21
+
cursor := r.URL.Query().Get("cursor")
22
+
23
+
// limit := 50 // default
24
+
// if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
25
+
// if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
26
+
// limit = l
27
+
// }
28
+
// }
29
+
30
+
limit := 500
31
+
32
+
gr, err := git.PlainOpen(repoPath)
33
+
if err != nil {
34
+
writeError(w, xrpcerr.NewXrpcError(
35
+
xrpcerr.WithTag("RepoNotFound"),
36
+
xrpcerr.WithMessage("repository not found"),
37
+
), http.StatusNotFound)
38
+
return
39
+
}
40
+
41
+
branches, _ := gr.Branches()
42
+
43
+
offset := 0
44
+
if cursor != "" {
45
+
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) {
46
+
offset = o
47
+
}
48
+
}
49
+
50
+
end := offset + limit
51
+
if end > len(branches) {
52
+
end = len(branches)
53
+
}
54
+
55
+
paginatedBranches := branches[offset:end]
56
+
57
+
// Create response using existing types.RepoBranchesResponse
58
+
response := types.RepoBranchesResponse{
59
+
Branches: paginatedBranches,
60
+
}
61
+
62
+
// Write JSON response directly
63
+
w.Header().Set("Content-Type", "application/json")
64
+
if err := json.NewEncoder(w).Encode(response); err != nil {
65
+
x.Logger.Error("failed to encode response", "error", err)
66
+
writeError(w, xrpcerr.NewXrpcError(
67
+
xrpcerr.WithTag("InternalServerError"),
68
+
xrpcerr.WithMessage("failed to encode response"),
69
+
), http.StatusInternalServerError)
70
+
return
71
+
}
72
+
}
+98
knotserver/xrpc/repo_compare.go
+98
knotserver/xrpc/repo_compare.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"net/url"
8
+
9
+
"tangled.sh/tangled.sh/core/knotserver/git"
10
+
"tangled.sh/tangled.sh/core/types"
11
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
12
+
)
13
+
14
+
func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
15
+
repo := r.URL.Query().Get("repo")
16
+
repoPath, err := x.parseRepoParam(repo)
17
+
if err != nil {
18
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
19
+
return
20
+
}
21
+
22
+
rev1Param := r.URL.Query().Get("rev1")
23
+
if rev1Param == "" {
24
+
writeError(w, xrpcerr.NewXrpcError(
25
+
xrpcerr.WithTag("InvalidRequest"),
26
+
xrpcerr.WithMessage("missing rev1 parameter"),
27
+
), http.StatusBadRequest)
28
+
return
29
+
}
30
+
31
+
rev2Param := r.URL.Query().Get("rev2")
32
+
if rev2Param == "" {
33
+
writeError(w, xrpcerr.NewXrpcError(
34
+
xrpcerr.WithTag("InvalidRequest"),
35
+
xrpcerr.WithMessage("missing rev2 parameter"),
36
+
), http.StatusBadRequest)
37
+
return
38
+
}
39
+
40
+
rev1, _ := url.PathUnescape(rev1Param)
41
+
rev2, _ := url.PathUnescape(rev2Param)
42
+
43
+
gr, err := git.PlainOpen(repoPath)
44
+
if err != nil {
45
+
writeError(w, xrpcerr.NewXrpcError(
46
+
xrpcerr.WithTag("RepoNotFound"),
47
+
xrpcerr.WithMessage("repository not found"),
48
+
), http.StatusNotFound)
49
+
return
50
+
}
51
+
52
+
commit1, err := gr.ResolveRevision(rev1)
53
+
if err != nil {
54
+
x.Logger.Error("error resolving revision 1", "msg", err.Error())
55
+
writeError(w, xrpcerr.NewXrpcError(
56
+
xrpcerr.WithTag("RevisionNotFound"),
57
+
xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)),
58
+
), http.StatusBadRequest)
59
+
return
60
+
}
61
+
62
+
commit2, err := gr.ResolveRevision(rev2)
63
+
if err != nil {
64
+
x.Logger.Error("error resolving revision 2", "msg", err.Error())
65
+
writeError(w, xrpcerr.NewXrpcError(
66
+
xrpcerr.WithTag("RevisionNotFound"),
67
+
xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)),
68
+
), http.StatusBadRequest)
69
+
return
70
+
}
71
+
72
+
rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2)
73
+
if err != nil {
74
+
x.Logger.Error("error comparing revisions", "msg", err.Error())
75
+
writeError(w, xrpcerr.NewXrpcError(
76
+
xrpcerr.WithTag("CompareError"),
77
+
xrpcerr.WithMessage("error comparing revisions"),
78
+
), http.StatusBadRequest)
79
+
return
80
+
}
81
+
82
+
resp := types.RepoFormatPatchResponse{
83
+
Rev1: commit1.Hash.String(),
84
+
Rev2: commit2.Hash.String(),
85
+
FormatPatch: formatPatch,
86
+
Patch: rawPatch,
87
+
}
88
+
89
+
w.Header().Set("Content-Type", "application/json")
90
+
if err := json.NewEncoder(w).Encode(resp); err != nil {
91
+
x.Logger.Error("failed to encode response", "error", err)
92
+
writeError(w, xrpcerr.NewXrpcError(
93
+
xrpcerr.WithTag("InternalServerError"),
94
+
xrpcerr.WithMessage("failed to encode response"),
95
+
), http.StatusInternalServerError)
96
+
return
97
+
}
98
+
}
+65
knotserver/xrpc/repo_diff.go
+65
knotserver/xrpc/repo_diff.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
"net/url"
7
+
8
+
"tangled.sh/tangled.sh/core/knotserver/git"
9
+
"tangled.sh/tangled.sh/core/types"
10
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
)
12
+
13
+
func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
14
+
repo := r.URL.Query().Get("repo")
15
+
repoPath, err := x.parseRepoParam(repo)
16
+
if err != nil {
17
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
18
+
return
19
+
}
20
+
21
+
refParam := r.URL.Query().Get("ref")
22
+
if refParam == "" {
23
+
writeError(w, xrpcerr.NewXrpcError(
24
+
xrpcerr.WithTag("InvalidRequest"),
25
+
xrpcerr.WithMessage("missing ref parameter"),
26
+
), http.StatusBadRequest)
27
+
return
28
+
}
29
+
30
+
ref, _ := url.QueryUnescape(refParam)
31
+
32
+
gr, err := git.Open(repoPath, ref)
33
+
if err != nil {
34
+
writeError(w, xrpcerr.NewXrpcError(
35
+
xrpcerr.WithTag("RefNotFound"),
36
+
xrpcerr.WithMessage("repository or ref not found"),
37
+
), http.StatusNotFound)
38
+
return
39
+
}
40
+
41
+
diff, err := gr.Diff()
42
+
if err != nil {
43
+
x.Logger.Error("getting diff", "error", err.Error())
44
+
writeError(w, xrpcerr.NewXrpcError(
45
+
xrpcerr.WithTag("RefNotFound"),
46
+
xrpcerr.WithMessage("failed to generate diff"),
47
+
), http.StatusInternalServerError)
48
+
return
49
+
}
50
+
51
+
resp := types.RepoCommitResponse{
52
+
Ref: ref,
53
+
Diff: diff,
54
+
}
55
+
56
+
w.Header().Set("Content-Type", "application/json")
57
+
if err := json.NewEncoder(w).Encode(resp); err != nil {
58
+
x.Logger.Error("failed to encode response", "error", err)
59
+
writeError(w, xrpcerr.NewXrpcError(
60
+
xrpcerr.WithTag("InternalServerError"),
61
+
xrpcerr.WithMessage("failed to encode response"),
62
+
), http.StatusInternalServerError)
63
+
return
64
+
}
65
+
}
+54
knotserver/xrpc/repo_get_default_branch.go
+54
knotserver/xrpc/repo_get_default_branch.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
7
+
"tangled.sh/tangled.sh/core/api/tangled"
8
+
"tangled.sh/tangled.sh/core/knotserver/git"
9
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
10
+
)
11
+
12
+
func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) {
13
+
repo := r.URL.Query().Get("repo")
14
+
repoPath, err := x.parseRepoParam(repo)
15
+
if err != nil {
16
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
17
+
return
18
+
}
19
+
20
+
gr, err := git.Open(repoPath, "")
21
+
if err != nil {
22
+
writeError(w, xrpcerr.NewXrpcError(
23
+
xrpcerr.WithTag("RepoNotFound"),
24
+
xrpcerr.WithMessage("repository not found"),
25
+
), http.StatusNotFound)
26
+
return
27
+
}
28
+
29
+
branch, err := gr.FindMainBranch()
30
+
if err != nil {
31
+
x.Logger.Error("getting default branch", "error", err.Error())
32
+
writeError(w, xrpcerr.NewXrpcError(
33
+
xrpcerr.WithTag("InvalidRequest"),
34
+
xrpcerr.WithMessage("failed to get default branch"),
35
+
), http.StatusInternalServerError)
36
+
return
37
+
}
38
+
39
+
response := tangled.RepoGetDefaultBranch_Output{
40
+
Name: branch,
41
+
Hash: "",
42
+
When: "1970-01-01T00:00:00.000Z",
43
+
}
44
+
45
+
w.Header().Set("Content-Type", "application/json")
46
+
if err := json.NewEncoder(w).Encode(response); err != nil {
47
+
x.Logger.Error("failed to encode response", "error", err)
48
+
writeError(w, xrpcerr.NewXrpcError(
49
+
xrpcerr.WithTag("InternalServerError"),
50
+
xrpcerr.WithMessage("failed to encode response"),
51
+
), http.StatusInternalServerError)
52
+
return
53
+
}
54
+
}
+93
knotserver/xrpc/repo_languages.go
+93
knotserver/xrpc/repo_languages.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"math"
7
+
"net/http"
8
+
"net/url"
9
+
"time"
10
+
11
+
"tangled.sh/tangled.sh/core/api/tangled"
12
+
"tangled.sh/tangled.sh/core/knotserver/git"
13
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
14
+
)
15
+
16
+
func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
17
+
refParam := r.URL.Query().Get("ref")
18
+
if refParam == "" {
19
+
refParam = "HEAD" // default
20
+
}
21
+
ref, _ := url.PathUnescape(refParam)
22
+
23
+
repo := r.URL.Query().Get("repo")
24
+
repoPath, err := x.parseRepoParam(repo)
25
+
if err != nil {
26
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
27
+
return
28
+
}
29
+
30
+
gr, err := git.Open(repoPath, ref)
31
+
if err != nil {
32
+
x.Logger.Error("opening repo", "error", err.Error())
33
+
writeError(w, xrpcerr.NewXrpcError(
34
+
xrpcerr.WithTag("RefNotFound"),
35
+
xrpcerr.WithMessage("repository or ref not found"),
36
+
), http.StatusNotFound)
37
+
return
38
+
}
39
+
40
+
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
41
+
defer cancel()
42
+
43
+
sizes, err := gr.AnalyzeLanguages(ctx)
44
+
if err != nil {
45
+
x.Logger.Error("failed to analyze languages", "error", err.Error())
46
+
writeError(w, xrpcerr.NewXrpcError(
47
+
xrpcerr.WithTag("InvalidRequest"),
48
+
xrpcerr.WithMessage("failed to analyze repository languages"),
49
+
), http.StatusNoContent)
50
+
return
51
+
}
52
+
53
+
var apiLanguages []*tangled.RepoLanguages_Language
54
+
var totalSize int64
55
+
56
+
for _, size := range sizes {
57
+
totalSize += size
58
+
}
59
+
60
+
for name, size := range sizes {
61
+
percentagef64 := float64(size) / float64(totalSize) * 100
62
+
percentage := math.Round(percentagef64)
63
+
64
+
lang := &tangled.RepoLanguages_Language{
65
+
Name: name,
66
+
Size: size,
67
+
Percentage: int64(percentage),
68
+
}
69
+
70
+
apiLanguages = append(apiLanguages, lang)
71
+
}
72
+
73
+
response := tangled.RepoLanguages_Output{
74
+
Ref: ref,
75
+
Languages: apiLanguages,
76
+
}
77
+
78
+
if totalSize > 0 {
79
+
response.TotalSize = &totalSize
80
+
totalFiles := int64(len(sizes))
81
+
response.TotalFiles = &totalFiles
82
+
}
83
+
84
+
w.Header().Set("Content-Type", "application/json")
85
+
if err := json.NewEncoder(w).Encode(response); err != nil {
86
+
x.Logger.Error("failed to encode response", "error", err)
87
+
writeError(w, xrpcerr.NewXrpcError(
88
+
xrpcerr.WithTag("InternalServerError"),
89
+
xrpcerr.WithMessage("failed to encode response"),
90
+
), http.StatusInternalServerError)
91
+
return
92
+
}
93
+
}
+111
knotserver/xrpc/repo_log.go
+111
knotserver/xrpc/repo_log.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
"net/url"
7
+
"strconv"
8
+
9
+
"tangled.sh/tangled.sh/core/knotserver/git"
10
+
"tangled.sh/tangled.sh/core/types"
11
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
12
+
)
13
+
14
+
func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) {
15
+
repo := r.URL.Query().Get("repo")
16
+
repoPath, err := x.parseRepoParam(repo)
17
+
if err != nil {
18
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
19
+
return
20
+
}
21
+
22
+
refParam := r.URL.Query().Get("ref")
23
+
if refParam == "" {
24
+
writeError(w, xrpcerr.NewXrpcError(
25
+
xrpcerr.WithTag("InvalidRequest"),
26
+
xrpcerr.WithMessage("missing ref parameter"),
27
+
), http.StatusBadRequest)
28
+
return
29
+
}
30
+
31
+
path := r.URL.Query().Get("path")
32
+
cursor := r.URL.Query().Get("cursor")
33
+
34
+
limit := 50 // default
35
+
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
36
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
37
+
limit = l
38
+
}
39
+
}
40
+
41
+
ref, err := url.QueryUnescape(refParam)
42
+
if err != nil {
43
+
writeError(w, xrpcerr.NewXrpcError(
44
+
xrpcerr.WithTag("InvalidRequest"),
45
+
xrpcerr.WithMessage("invalid ref parameter"),
46
+
), http.StatusBadRequest)
47
+
return
48
+
}
49
+
50
+
gr, err := git.Open(repoPath, ref)
51
+
if err != nil {
52
+
writeError(w, xrpcerr.NewXrpcError(
53
+
xrpcerr.WithTag("RefNotFound"),
54
+
xrpcerr.WithMessage("repository or ref not found"),
55
+
), http.StatusNotFound)
56
+
return
57
+
}
58
+
59
+
offset := 0
60
+
if cursor != "" {
61
+
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 {
62
+
offset = o
63
+
}
64
+
}
65
+
66
+
commits, err := gr.Commits(offset, limit)
67
+
if err != nil {
68
+
x.Logger.Error("fetching commits", "error", err.Error())
69
+
writeError(w, xrpcerr.NewXrpcError(
70
+
xrpcerr.WithTag("PathNotFound"),
71
+
xrpcerr.WithMessage("failed to read commit log"),
72
+
), http.StatusNotFound)
73
+
return
74
+
}
75
+
76
+
total, err := gr.TotalCommits()
77
+
if err != nil {
78
+
x.Logger.Error("fetching total commits", "error", err.Error())
79
+
writeError(w, xrpcerr.NewXrpcError(
80
+
xrpcerr.WithTag("InternalServerError"),
81
+
xrpcerr.WithMessage("failed to fetch total commits"),
82
+
), http.StatusNotFound)
83
+
return
84
+
}
85
+
86
+
// Create response using existing types.RepoLogResponse
87
+
response := types.RepoLogResponse{
88
+
Commits: commits,
89
+
Ref: ref,
90
+
Page: (offset / limit) + 1,
91
+
PerPage: limit,
92
+
Total: total,
93
+
}
94
+
95
+
if path != "" {
96
+
response.Description = path
97
+
}
98
+
99
+
response.Log = true
100
+
101
+
// Write JSON response directly
102
+
w.Header().Set("Content-Type", "application/json")
103
+
if err := json.NewEncoder(w).Encode(response); err != nil {
104
+
x.Logger.Error("failed to encode response", "error", err)
105
+
writeError(w, xrpcerr.NewXrpcError(
106
+
xrpcerr.WithTag("InternalServerError"),
107
+
xrpcerr.WithMessage("failed to encode response"),
108
+
), http.StatusInternalServerError)
109
+
return
110
+
}
111
+
}
+116
knotserver/xrpc/repo_tree.go
+116
knotserver/xrpc/repo_tree.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
"net/url"
7
+
"path/filepath"
8
+
9
+
"tangled.sh/tangled.sh/core/api/tangled"
10
+
"tangled.sh/tangled.sh/core/knotserver/git"
11
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
12
+
)
13
+
14
+
func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) {
15
+
ctx := r.Context()
16
+
17
+
repo := r.URL.Query().Get("repo")
18
+
repoPath, err := x.parseRepoParam(repo)
19
+
if err != nil {
20
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
21
+
return
22
+
}
23
+
24
+
refParam := r.URL.Query().Get("ref")
25
+
if refParam == "" {
26
+
writeError(w, xrpcerr.NewXrpcError(
27
+
xrpcerr.WithTag("InvalidRequest"),
28
+
xrpcerr.WithMessage("missing ref parameter"),
29
+
), http.StatusBadRequest)
30
+
return
31
+
}
32
+
33
+
path := r.URL.Query().Get("path")
34
+
// path can be empty (defaults to root)
35
+
36
+
ref, err := url.QueryUnescape(refParam)
37
+
if err != nil {
38
+
writeError(w, xrpcerr.NewXrpcError(
39
+
xrpcerr.WithTag("InvalidRequest"),
40
+
xrpcerr.WithMessage("invalid ref parameter"),
41
+
), http.StatusBadRequest)
42
+
return
43
+
}
44
+
45
+
gr, err := git.Open(repoPath, ref)
46
+
if err != nil {
47
+
x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref)
48
+
writeError(w, xrpcerr.NewXrpcError(
49
+
xrpcerr.WithTag("RefNotFound"),
50
+
xrpcerr.WithMessage("repository or ref not found"),
51
+
), http.StatusNotFound)
52
+
return
53
+
}
54
+
55
+
files, err := gr.FileTree(ctx, path)
56
+
if err != nil {
57
+
x.Logger.Error("failed to get file tree", "error", err, "path", path)
58
+
writeError(w, xrpcerr.NewXrpcError(
59
+
xrpcerr.WithTag("PathNotFound"),
60
+
xrpcerr.WithMessage("failed to read repository tree"),
61
+
), http.StatusNotFound)
62
+
return
63
+
}
64
+
65
+
// convert NiceTree -> tangled.RepoTree_TreeEntry
66
+
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
67
+
for i, file := range files {
68
+
entry := &tangled.RepoTree_TreeEntry{
69
+
Name: file.Name,
70
+
Mode: file.Mode,
71
+
Size: file.Size,
72
+
Is_file: file.IsFile,
73
+
Is_subtree: file.IsSubtree,
74
+
}
75
+
76
+
if file.LastCommit != nil {
77
+
entry.Last_commit = &tangled.RepoTree_LastCommit{
78
+
Hash: file.LastCommit.Hash.String(),
79
+
Message: file.LastCommit.Message,
80
+
When: file.LastCommit.When.Format("2006-01-02T15:04:05.000Z"),
81
+
}
82
+
}
83
+
84
+
treeEntries[i] = entry
85
+
}
86
+
87
+
var parentPtr *string
88
+
if path != "" {
89
+
parentPtr = &path
90
+
}
91
+
92
+
var dotdotPtr *string
93
+
if path != "" {
94
+
dotdot := filepath.Dir(path)
95
+
if dotdot != "." {
96
+
dotdotPtr = &dotdot
97
+
}
98
+
}
99
+
100
+
response := tangled.RepoTree_Output{
101
+
Ref: ref,
102
+
Parent: parentPtr,
103
+
Dotdot: dotdotPtr,
104
+
Files: treeEntries,
105
+
}
106
+
107
+
w.Header().Set("Content-Type", "application/json")
108
+
if err := json.NewEncoder(w).Encode(response); err != nil {
109
+
x.Logger.Error("failed to encode response", "error", err)
110
+
writeError(w, xrpcerr.NewXrpcError(
111
+
xrpcerr.WithTag("InternalServerError"),
112
+
xrpcerr.WithMessage("failed to encode response"),
113
+
), http.StatusInternalServerError)
114
+
return
115
+
}
116
+
}
+70
knotserver/xrpc/version.go
+70
knotserver/xrpc/version.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"runtime/debug"
8
+
9
+
"tangled.sh/tangled.sh/core/api/tangled"
10
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
)
12
+
13
+
// version is set during build time.
14
+
var version string
15
+
16
+
func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) {
17
+
if version == "" {
18
+
info, ok := debug.ReadBuildInfo()
19
+
if !ok {
20
+
http.Error(w, "failed to read build info", http.StatusInternalServerError)
21
+
return
22
+
}
23
+
24
+
var modVer string
25
+
var sha string
26
+
var modified bool
27
+
28
+
for _, mod := range info.Deps {
29
+
if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" {
30
+
modVer = mod.Version
31
+
break
32
+
}
33
+
}
34
+
35
+
for _, setting := range info.Settings {
36
+
switch setting.Key {
37
+
case "vcs.revision":
38
+
sha = setting.Value
39
+
case "vcs.modified":
40
+
modified = setting.Value == "true"
41
+
}
42
+
}
43
+
44
+
if modVer == "" {
45
+
modVer = "unknown"
46
+
}
47
+
48
+
if sha == "" {
49
+
version = modVer
50
+
} else if modified {
51
+
version = fmt.Sprintf("%s (%s with modifications)", modVer, sha)
52
+
} else {
53
+
version = fmt.Sprintf("%s (%s)", modVer, sha)
54
+
}
55
+
}
56
+
57
+
response := tangled.KnotVersion_Output{
58
+
Version: version,
59
+
}
60
+
61
+
w.Header().Set("Content-Type", "application/json")
62
+
if err := json.NewEncoder(w).Encode(response); err != nil {
63
+
x.Logger.Error("failed to encode response", "error", err)
64
+
writeError(w, xrpcerr.NewXrpcError(
65
+
xrpcerr.WithTag("InternalServerError"),
66
+
xrpcerr.WithMessage("failed to encode response"),
67
+
), http.StatusInternalServerError)
68
+
return
69
+
}
70
+
}
+88
knotserver/xrpc/xrpc.go
+88
knotserver/xrpc/xrpc.go
···
4
4
"encoding/json"
5
5
"log/slog"
6
6
"net/http"
7
+
"net/url"
8
+
"strings"
7
9
10
+
securejoin "github.com/cyphar/filepath-securejoin"
8
11
"tangled.sh/tangled.sh/core/api/tangled"
9
12
"tangled.sh/tangled.sh/core/idresolver"
10
13
"tangled.sh/tangled.sh/core/jetstream"
···
50
53
// - we can calculate on PR submit/resubmit/gitRefUpdate etc.
51
54
// - use ETags on clients to keep requests to a minimum
52
55
r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck)
56
+
57
+
// repo query endpoints (no auth required)
58
+
r.Get("/"+tangled.RepoTreeNSID, x.RepoTree)
59
+
r.Get("/"+tangled.RepoLogNSID, x.RepoLog)
60
+
r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches)
61
+
r.Get("/"+tangled.RepoTagsNSID, x.RepoTags)
62
+
r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob)
63
+
r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff)
64
+
r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare)
65
+
r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch)
66
+
r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch)
67
+
r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive)
68
+
r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages)
69
+
70
+
// knot query endpoints (no auth required)
71
+
r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys)
72
+
r.Get("/"+tangled.KnotVersionNSID, x.Version)
73
+
74
+
// service query endpoints (no auth required)
75
+
r.Get("/"+tangled.OwnerNSID, x.Owner)
76
+
53
77
return r
78
+
}
79
+
80
+
// parseRepoParam parses a repo parameter in 'did/repoName' format and returns
81
+
// the full repository path on disk
82
+
func (x *Xrpc) parseRepoParam(repo string) (string, error) {
83
+
if repo == "" {
84
+
return "", xrpcerr.NewXrpcError(
85
+
xrpcerr.WithTag("InvalidRequest"),
86
+
xrpcerr.WithMessage("missing repo parameter"),
87
+
)
88
+
}
89
+
90
+
// Parse repo string (did/repoName format)
91
+
parts := strings.Split(repo, "/")
92
+
if len(parts) < 2 {
93
+
return "", xrpcerr.NewXrpcError(
94
+
xrpcerr.WithTag("InvalidRequest"),
95
+
xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"),
96
+
)
97
+
}
98
+
99
+
did := strings.Join(parts[:len(parts)-1], "/")
100
+
repoName := parts[len(parts)-1]
101
+
102
+
// Construct repository path using the same logic as didPath
103
+
didRepoPath, err := securejoin.SecureJoin(did, repoName)
104
+
if err != nil {
105
+
return "", xrpcerr.NewXrpcError(
106
+
xrpcerr.WithTag("RepoNotFound"),
107
+
xrpcerr.WithMessage("failed to access repository"),
108
+
)
109
+
}
110
+
111
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath)
112
+
if err != nil {
113
+
return "", xrpcerr.NewXrpcError(
114
+
xrpcerr.WithTag("RepoNotFound"),
115
+
xrpcerr.WithMessage("failed to access repository"),
116
+
)
117
+
}
118
+
119
+
return repoPath, nil
120
+
}
121
+
122
+
// parseStandardParams parses common query parameters used by most handlers
123
+
func (x *Xrpc) parseStandardParams(r *http.Request) (repo, repoPath, ref string, err error) {
124
+
// Parse repo parameter
125
+
repo = r.URL.Query().Get("repo")
126
+
repoPath, err = x.parseRepoParam(repo)
127
+
if err != nil {
128
+
return "", "", "", err
129
+
}
130
+
131
+
// Parse and unescape ref parameter
132
+
refParam := r.URL.Query().Get("ref")
133
+
if refParam == "" {
134
+
return "", "", "", xrpcerr.NewXrpcError(
135
+
xrpcerr.WithTag("InvalidRequest"),
136
+
xrpcerr.WithMessage("missing ref parameter"),
137
+
)
138
+
}
139
+
140
+
ref, _ = url.QueryUnescape(refParam)
141
+
return repo, repoPath, ref, nil
54
142
}
55
143
56
144
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
+158
legal/privacy.md
+158
legal/privacy.md
···
1
+
# Privacy Policy
2
+
3
+
**Last updated:** January 15, 2025
4
+
5
+
This Privacy Policy describes how Tangled ("we," "us," or "our")
6
+
collects, uses, and shares your personal information when you use our
7
+
platform and services (the "Service").
8
+
9
+
## 1. Information We Collect
10
+
11
+
### Account Information
12
+
13
+
When you create an account, we collect:
14
+
15
+
- Your chosen username
16
+
- Email address
17
+
- Profile information you choose to provide
18
+
- Authentication data
19
+
20
+
### Content and Activity
21
+
22
+
We store:
23
+
24
+
- Code repositories and associated metadata
25
+
- Issues, pull requests, and comments
26
+
- Activity logs and usage patterns
27
+
- Public keys for authentication
28
+
29
+
## 2. Data Location and Hosting
30
+
31
+
### EU Data Hosting
32
+
33
+
**All Tangled service data is hosted within the European Union.**
34
+
Specifically:
35
+
36
+
- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
37
+
(*.tngl.sh) are located in Finland
38
+
- **Application Data:** All other service data is stored on EU-based
39
+
servers
40
+
- **Data Processing:** All data processing occurs within EU
41
+
jurisdiction
42
+
43
+
### External PDS Notice
44
+
45
+
**Important:** If your account is hosted on Bluesky's PDS or other
46
+
self-hosted Personal Data Servers (not *.tngl.sh), we do not control
47
+
that data. The data protection, storage location, and privacy
48
+
practices for such accounts are governed by the respective PDS
49
+
provider's policies, not this Privacy Policy. We only control data
50
+
processing within our own services and infrastructure.
51
+
52
+
## 3. Third-Party Data Processors
53
+
54
+
We only share your data with the following third-party processors:
55
+
56
+
### Resend (Email Services)
57
+
58
+
- **Purpose:** Sending transactional emails (account verification,
59
+
notifications)
60
+
- **Data Shared:** Email address and necessary message content
61
+
62
+
### Cloudflare (Image Caching)
63
+
64
+
- **Purpose:** Caching and optimizing image delivery
65
+
- **Data Shared:** Public images and associated metadata for caching
66
+
purposes
67
+
68
+
### Posthog (Usage Metrics Tracking)
69
+
70
+
- **Purpose:** Tracking usage and platform metrics
71
+
- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
72
+
information
73
+
74
+
## 4. How We Use Your Information
75
+
76
+
We use your information to:
77
+
78
+
- Provide and maintain the Service
79
+
- Process your transactions and requests
80
+
- Send you technical notices and support messages
81
+
- Improve and develop new features
82
+
- Ensure security and prevent fraud
83
+
- Comply with legal obligations
84
+
85
+
## 5. Data Sharing and Disclosure
86
+
87
+
We do not sell, trade, or rent your personal information. We may share
88
+
your information only in the following circumstances:
89
+
90
+
- With the third-party processors listed above
91
+
- When required by law or legal process
92
+
- To protect our rights, property, or safety, or that of our users
93
+
- In connection with a merger, acquisition, or sale of assets (with
94
+
appropriate protections)
95
+
96
+
## 6. Data Security
97
+
98
+
We implement appropriate technical and organizational measures to
99
+
protect your personal information against unauthorized access,
100
+
alteration, disclosure, or destruction. However, no method of
101
+
transmission over the Internet is 100% secure.
102
+
103
+
## 7. Data Retention
104
+
105
+
We retain your personal information for as long as necessary to provide
106
+
the Service and fulfill the purposes outlined in this Privacy Policy,
107
+
unless a longer retention period is required by law.
108
+
109
+
## 8. Your Rights
110
+
111
+
Under applicable data protection laws, you have the right to:
112
+
113
+
- Access your personal information
114
+
- Correct inaccurate information
115
+
- Request deletion of your information
116
+
- Object to processing of your information
117
+
- Data portability
118
+
- Withdraw consent (where applicable)
119
+
120
+
## 9. Cookies and Tracking
121
+
122
+
We use cookies and similar technologies to:
123
+
124
+
- Maintain your login session
125
+
- Remember your preferences
126
+
- Analyze usage patterns to improve the Service
127
+
128
+
You can control cookie settings through your browser preferences.
129
+
130
+
## 10. Children's Privacy
131
+
132
+
The Service is not intended for children under 16 years of age. We do
133
+
not knowingly collect personal information from children under 16. If
134
+
we become aware that we have collected such information, we will take
135
+
steps to delete it.
136
+
137
+
## 11. International Data Transfers
138
+
139
+
While all our primary data processing occurs within the EU, some of our
140
+
third-party processors may process data outside the EU. When this
141
+
occurs, we ensure appropriate safeguards are in place, such as Standard
142
+
Contractual Clauses or adequacy decisions.
143
+
144
+
## 12. Changes to This Privacy Policy
145
+
146
+
We may update this Privacy Policy from time to time. We will notify you
147
+
of any changes by posting the new Privacy Policy on this page and
148
+
updating the "Last updated" date.
149
+
150
+
## 13. Contact Information
151
+
152
+
If you have any questions about this Privacy Policy or wish to exercise
153
+
your rights, please contact us through our platform or via email.
154
+
155
+
---
156
+
157
+
This Privacy Policy complies with the EU General Data Protection
158
+
Regulation (GDPR) and other applicable data protection laws.
+109
legal/terms.md
+109
legal/terms.md
···
1
+
# Terms of Service
2
+
3
+
**Last updated:** January 15, 2025
4
+
5
+
Welcome to Tangled. These Terms of Service ("Terms") govern your access
6
+
to and use of the Tangled platform and services (the "Service")
7
+
operated by us ("Tangled," "we," "us," or "our").
8
+
9
+
## 1. Acceptance of Terms
10
+
11
+
By accessing or using our Service, you agree to be bound by these Terms.
12
+
If you disagree with any part of these terms, then you may not access
13
+
the Service.
14
+
15
+
## 2. Account Registration
16
+
17
+
To use certain features of the Service, you must register for an
18
+
account. You agree to provide accurate, current, and complete
19
+
information during the registration process and to update such
20
+
information to keep it accurate, current, and complete.
21
+
22
+
## 3. Account Termination
23
+
24
+
> **Important Notice**
25
+
>
26
+
> **We reserve the right to terminate, suspend, or restrict access to
27
+
> your account at any time, for any reason, or for no reason at all, at
28
+
> our sole discretion.** This includes, but is not limited to,
29
+
> termination for violation of these Terms, inappropriate conduct, spam,
30
+
> abuse, or any other behavior we deem harmful to the Service or other
31
+
> users.
32
+
>
33
+
> Account termination may result in the loss of access to your
34
+
> repositories, data, and other content associated with your account. We
35
+
> are not obligated to provide advance notice of termination, though we
36
+
> may do so in our discretion.
37
+
38
+
## 4. Acceptable Use
39
+
40
+
You agree not to use the Service to:
41
+
42
+
- Violate any applicable laws or regulations
43
+
- Infringe upon the rights of others
44
+
- Upload, store, or share content that is illegal, harmful, threatening,
45
+
abusive, harassing, defamatory, vulgar, obscene, or otherwise
46
+
objectionable
47
+
- Engage in spam, phishing, or other deceptive practices
48
+
- Attempt to gain unauthorized access to the Service or other users'
49
+
accounts
50
+
- Interfere with or disrupt the Service or servers connected to the
51
+
Service
52
+
53
+
## 5. Content and Intellectual Property
54
+
55
+
You retain ownership of the content you upload to the Service. By
56
+
uploading content, you grant us a non-exclusive, worldwide, royalty-free
57
+
license to use, reproduce, modify, and distribute your content as
58
+
necessary to provide the Service.
59
+
60
+
## 6. Privacy
61
+
62
+
Your privacy is important to us. Please review our [Privacy
63
+
Policy](/privacy), which also governs your use of the Service.
64
+
65
+
## 7. Disclaimers
66
+
67
+
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
68
+
no warranties, expressed or implied, and hereby disclaim and negate all
69
+
other warranties including without limitation, implied warranties or
70
+
conditions of merchantability, fitness for a particular purpose, or
71
+
non-infringement of intellectual property or other violation of rights.
72
+
73
+
## 8. Limitation of Liability
74
+
75
+
In no event shall Tangled, nor its directors, employees, partners,
76
+
agents, suppliers, or affiliates, be liable for any indirect,
77
+
incidental, special, consequential, or punitive damages, including
78
+
without limitation, loss of profits, data, use, goodwill, or other
79
+
intangible losses, resulting from your use of the Service.
80
+
81
+
## 9. Indemnification
82
+
83
+
You agree to defend, indemnify, and hold harmless Tangled and its
84
+
affiliates, officers, directors, employees, and agents from and against
85
+
any and all claims, damages, obligations, losses, liabilities, costs,
86
+
or debt, and expenses (including attorney's fees).
87
+
88
+
## 10. Governing Law
89
+
90
+
These Terms shall be interpreted and governed by the laws of Finland,
91
+
without regard to its conflict of law provisions.
92
+
93
+
## 11. Changes to Terms
94
+
95
+
We reserve the right to modify or replace these Terms at any time. If a
96
+
revision is material, we will try to provide at least 30 days notice
97
+
prior to any new terms taking effect.
98
+
99
+
## 12. Contact Information
100
+
101
+
If you have any questions about these Terms of Service, please contact
102
+
us through our platform or via email.
103
+
104
+
---
105
+
106
+
These terms are effective as of the last updated date shown above and
107
+
will remain in effect except with respect to any changes in their
108
+
provisions in the future, which will be in effect immediately after
109
+
being posted on this page.
+9
-9
lexicons/issue/comment.json
+9
-9
lexicons/issue/comment.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": ["issue", "body", "createdAt"],
12
+
"required": [
13
+
"issue",
14
+
"body",
15
+
"createdAt"
16
+
],
13
17
"properties": {
14
18
"issue": {
15
19
"type": "string",
16
20
"format": "at-uri"
17
21
},
18
-
"repo": {
19
-
"type": "string",
20
-
"format": "at-uri"
21
-
},
22
-
"owner": {
23
-
"type": "string",
24
-
"format": "did"
25
-
},
26
22
"body": {
27
23
"type": "string"
28
24
},
29
25
"createdAt": {
30
26
"type": "string",
31
27
"format": "datetime"
28
+
},
29
+
"replyTo": {
30
+
"type": "string",
31
+
"format": "at-uri"
32
32
}
33
33
}
34
34
}
+73
lexicons/knot/listKeys.json
+73
lexicons/knot/listKeys.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.knot.listKeys",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"description": "List all public keys stored in the knot server",
8
+
"parameters": {
9
+
"type": "params",
10
+
"properties": {
11
+
"limit": {
12
+
"type": "integer",
13
+
"description": "Maximum number of keys to return",
14
+
"minimum": 1,
15
+
"maximum": 1000,
16
+
"default": 100
17
+
},
18
+
"cursor": {
19
+
"type": "string",
20
+
"description": "Pagination cursor"
21
+
}
22
+
}
23
+
},
24
+
"output": {
25
+
"encoding": "application/json",
26
+
"schema": {
27
+
"type": "object",
28
+
"required": ["keys"],
29
+
"properties": {
30
+
"keys": {
31
+
"type": "array",
32
+
"items": {
33
+
"type": "ref",
34
+
"ref": "#publicKey"
35
+
}
36
+
},
37
+
"cursor": {
38
+
"type": "string",
39
+
"description": "Pagination cursor for next page"
40
+
}
41
+
}
42
+
}
43
+
},
44
+
"errors": [
45
+
{
46
+
"name": "InternalServerError",
47
+
"description": "Failed to retrieve public keys"
48
+
}
49
+
]
50
+
},
51
+
"publicKey": {
52
+
"type": "object",
53
+
"required": ["did", "key", "createdAt"],
54
+
"properties": {
55
+
"did": {
56
+
"type": "string",
57
+
"format": "did",
58
+
"description": "DID associated with the public key"
59
+
},
60
+
"key": {
61
+
"type": "string",
62
+
"maxLength": 4096,
63
+
"description": "Public key contents"
64
+
},
65
+
"createdAt": {
66
+
"type": "string",
67
+
"format": "datetime",
68
+
"description": "Key upload timestamp"
69
+
}
70
+
}
71
+
}
72
+
}
73
+
}
+25
lexicons/knot/version.json
+25
lexicons/knot/version.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.knot.version",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"description": "Get the version of a knot",
8
+
"output": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"version"
14
+
],
15
+
"properties": {
16
+
"version": {
17
+
"type": "string"
18
+
}
19
+
}
20
+
}
21
+
},
22
+
"errors": []
23
+
}
24
+
}
25
+
}
+31
lexicons/owner.json
+31
lexicons/owner.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.owner",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"description": "Get the owner of a service",
8
+
"output": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"owner"
14
+
],
15
+
"properties": {
16
+
"owner": {
17
+
"type": "string",
18
+
"format": "did"
19
+
}
20
+
}
21
+
}
22
+
},
23
+
"errors": [
24
+
{
25
+
"name": "OwnerNotFound",
26
+
"description": "Owner is not set for this service"
27
+
}
28
+
]
29
+
}
30
+
}
31
+
}
+55
lexicons/repo/archive.json
+55
lexicons/repo/archive.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.archive",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "ref"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)"
18
+
},
19
+
"format": {
20
+
"type": "string",
21
+
"description": "Archive format",
22
+
"enum": ["tar", "zip", "tar.gz", "tar.bz2", "tar.xz"],
23
+
"default": "tar.gz"
24
+
},
25
+
"prefix": {
26
+
"type": "string",
27
+
"description": "Prefix for files in the archive"
28
+
}
29
+
}
30
+
},
31
+
"output": {
32
+
"encoding": "*/*",
33
+
"description": "Binary archive data"
34
+
},
35
+
"errors": [
36
+
{
37
+
"name": "RepoNotFound",
38
+
"description": "Repository not found or access denied"
39
+
},
40
+
{
41
+
"name": "RefNotFound",
42
+
"description": "Git reference not found"
43
+
},
44
+
{
45
+
"name": "InvalidRequest",
46
+
"description": "Invalid request parameters"
47
+
},
48
+
{
49
+
"name": "ArchiveError",
50
+
"description": "Failed to create archive"
51
+
}
52
+
]
53
+
}
54
+
}
55
+
}
+138
lexicons/repo/blob.json
+138
lexicons/repo/blob.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.blob",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "ref", "path"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)"
18
+
},
19
+
"path": {
20
+
"type": "string",
21
+
"description": "Path to the file within the repository"
22
+
},
23
+
"raw": {
24
+
"type": "boolean",
25
+
"description": "Return raw file content instead of JSON response",
26
+
"default": false
27
+
}
28
+
}
29
+
},
30
+
"output": {
31
+
"encoding": "application/json",
32
+
"schema": {
33
+
"type": "object",
34
+
"required": ["ref", "path", "content"],
35
+
"properties": {
36
+
"ref": {
37
+
"type": "string",
38
+
"description": "The git reference used"
39
+
},
40
+
"path": {
41
+
"type": "string",
42
+
"description": "The file path"
43
+
},
44
+
"content": {
45
+
"type": "string",
46
+
"description": "File content (base64 encoded for binary files)"
47
+
},
48
+
"encoding": {
49
+
"type": "string",
50
+
"description": "Content encoding",
51
+
"enum": ["utf-8", "base64"]
52
+
},
53
+
"size": {
54
+
"type": "integer",
55
+
"description": "File size in bytes"
56
+
},
57
+
"isBinary": {
58
+
"type": "boolean",
59
+
"description": "Whether the file is binary"
60
+
},
61
+
"mimeType": {
62
+
"type": "string",
63
+
"description": "MIME type of the file"
64
+
},
65
+
"lastCommit": {
66
+
"type": "ref",
67
+
"ref": "#lastCommit"
68
+
}
69
+
}
70
+
}
71
+
},
72
+
"errors": [
73
+
{
74
+
"name": "RepoNotFound",
75
+
"description": "Repository not found or access denied"
76
+
},
77
+
{
78
+
"name": "RefNotFound",
79
+
"description": "Git reference not found"
80
+
},
81
+
{
82
+
"name": "FileNotFound",
83
+
"description": "File not found at the specified path"
84
+
},
85
+
{
86
+
"name": "InvalidRequest",
87
+
"description": "Invalid request parameters"
88
+
}
89
+
]
90
+
},
91
+
"lastCommit": {
92
+
"type": "object",
93
+
"required": ["hash", "message", "when"],
94
+
"properties": {
95
+
"hash": {
96
+
"type": "string",
97
+
"description": "Commit hash"
98
+
},
99
+
"shortHash": {
100
+
"type": "string",
101
+
"description": "Short commit hash"
102
+
},
103
+
"message": {
104
+
"type": "string",
105
+
"description": "Commit message"
106
+
},
107
+
"author": {
108
+
"type": "ref",
109
+
"ref": "#signature"
110
+
},
111
+
"when": {
112
+
"type": "string",
113
+
"format": "datetime",
114
+
"description": "Commit timestamp"
115
+
}
116
+
}
117
+
},
118
+
"signature": {
119
+
"type": "object",
120
+
"required": ["name", "email", "when"],
121
+
"properties": {
122
+
"name": {
123
+
"type": "string",
124
+
"description": "Author name"
125
+
},
126
+
"email": {
127
+
"type": "string",
128
+
"description": "Author email"
129
+
},
130
+
"when": {
131
+
"type": "string",
132
+
"format": "datetime",
133
+
"description": "Author timestamp"
134
+
}
135
+
}
136
+
}
137
+
}
138
+
}
+94
lexicons/repo/branch.json
+94
lexicons/repo/branch.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.branch",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "name"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"name": {
16
+
"type": "string",
17
+
"description": "Branch name to get information for"
18
+
}
19
+
}
20
+
},
21
+
"output": {
22
+
"encoding": "application/json",
23
+
"schema": {
24
+
"type": "object",
25
+
"required": ["name", "hash", "when"],
26
+
"properties": {
27
+
"name": {
28
+
"type": "string",
29
+
"description": "Branch name"
30
+
},
31
+
"hash": {
32
+
"type": "string",
33
+
"description": "Latest commit hash on this branch"
34
+
},
35
+
"shortHash": {
36
+
"type": "string",
37
+
"description": "Short commit hash"
38
+
},
39
+
"when": {
40
+
"type": "string",
41
+
"format": "datetime",
42
+
"description": "Timestamp of latest commit"
43
+
},
44
+
"message": {
45
+
"type": "string",
46
+
"description": "Latest commit message"
47
+
},
48
+
"author": {
49
+
"type": "ref",
50
+
"ref": "#signature"
51
+
},
52
+
"isDefault": {
53
+
"type": "boolean",
54
+
"description": "Whether this is the default branch"
55
+
}
56
+
}
57
+
}
58
+
},
59
+
"errors": [
60
+
{
61
+
"name": "RepoNotFound",
62
+
"description": "Repository not found or access denied"
63
+
},
64
+
{
65
+
"name": "BranchNotFound",
66
+
"description": "Branch not found"
67
+
},
68
+
{
69
+
"name": "InvalidRequest",
70
+
"description": "Invalid request parameters"
71
+
}
72
+
]
73
+
},
74
+
"signature": {
75
+
"type": "object",
76
+
"required": ["name", "email", "when"],
77
+
"properties": {
78
+
"name": {
79
+
"type": "string",
80
+
"description": "Author name"
81
+
},
82
+
"email": {
83
+
"type": "string",
84
+
"description": "Author email"
85
+
},
86
+
"when": {
87
+
"type": "string",
88
+
"format": "datetime",
89
+
"description": "Author timestamp"
90
+
}
91
+
}
92
+
}
93
+
}
94
+
}
+43
lexicons/repo/branches.json
+43
lexicons/repo/branches.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.branches",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"limit": {
16
+
"type": "integer",
17
+
"description": "Maximum number of branches to return",
18
+
"minimum": 1,
19
+
"maximum": 100,
20
+
"default": 50
21
+
},
22
+
"cursor": {
23
+
"type": "string",
24
+
"description": "Pagination cursor"
25
+
}
26
+
}
27
+
},
28
+
"output": {
29
+
"encoding": "*/*"
30
+
},
31
+
"errors": [
32
+
{
33
+
"name": "RepoNotFound",
34
+
"description": "Repository not found or access denied"
35
+
},
36
+
{
37
+
"name": "InvalidRequest",
38
+
"description": "Invalid request parameters"
39
+
}
40
+
]
41
+
}
42
+
}
43
+
}
+49
lexicons/repo/compare.json
+49
lexicons/repo/compare.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.compare",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "rev1", "rev2"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"rev1": {
16
+
"type": "string",
17
+
"description": "First revision (commit, branch, or tag)"
18
+
},
19
+
"rev2": {
20
+
"type": "string",
21
+
"description": "Second revision (commit, branch, or tag)"
22
+
}
23
+
}
24
+
},
25
+
"output": {
26
+
"encoding": "*/*",
27
+
"description": "Compare output in application/json"
28
+
},
29
+
"errors": [
30
+
{
31
+
"name": "RepoNotFound",
32
+
"description": "Repository not found or access denied"
33
+
},
34
+
{
35
+
"name": "RevisionNotFound",
36
+
"description": "One or both revisions not found"
37
+
},
38
+
{
39
+
"name": "InvalidRequest",
40
+
"description": "Invalid request parameters"
41
+
},
42
+
{
43
+
"name": "CompareError",
44
+
"description": "Failed to compare revisions"
45
+
}
46
+
]
47
+
}
48
+
}
49
+
}
+40
lexicons/repo/diff.json
+40
lexicons/repo/diff.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.diff",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "ref"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)"
18
+
}
19
+
}
20
+
},
21
+
"output": {
22
+
"encoding": "*/*"
23
+
},
24
+
"errors": [
25
+
{
26
+
"name": "RepoNotFound",
27
+
"description": "Repository not found or access denied"
28
+
},
29
+
{
30
+
"name": "RefNotFound",
31
+
"description": "Git reference not found"
32
+
},
33
+
{
34
+
"name": "InvalidRequest",
35
+
"description": "Invalid request parameters"
36
+
}
37
+
]
38
+
}
39
+
}
40
+
}
+82
lexicons/repo/getDefaultBranch.json
+82
lexicons/repo/getDefaultBranch.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.getDefaultBranch",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
}
15
+
}
16
+
},
17
+
"output": {
18
+
"encoding": "application/json",
19
+
"schema": {
20
+
"type": "object",
21
+
"required": ["name", "hash", "when"],
22
+
"properties": {
23
+
"name": {
24
+
"type": "string",
25
+
"description": "Default branch name"
26
+
},
27
+
"hash": {
28
+
"type": "string",
29
+
"description": "Latest commit hash on default branch"
30
+
},
31
+
"shortHash": {
32
+
"type": "string",
33
+
"description": "Short commit hash"
34
+
},
35
+
"when": {
36
+
"type": "string",
37
+
"format": "datetime",
38
+
"description": "Timestamp of latest commit"
39
+
},
40
+
"message": {
41
+
"type": "string",
42
+
"description": "Latest commit message"
43
+
},
44
+
"author": {
45
+
"type": "ref",
46
+
"ref": "#signature"
47
+
}
48
+
}
49
+
}
50
+
},
51
+
"errors": [
52
+
{
53
+
"name": "RepoNotFound",
54
+
"description": "Repository not found or access denied"
55
+
},
56
+
{
57
+
"name": "InvalidRequest",
58
+
"description": "Invalid request parameters"
59
+
}
60
+
]
61
+
},
62
+
"signature": {
63
+
"type": "object",
64
+
"required": ["name", "email", "when"],
65
+
"properties": {
66
+
"name": {
67
+
"type": "string",
68
+
"description": "Author name"
69
+
},
70
+
"email": {
71
+
"type": "string",
72
+
"description": "Author email"
73
+
},
74
+
"when": {
75
+
"type": "string",
76
+
"format": "datetime",
77
+
"description": "Author timestamp"
78
+
}
79
+
}
80
+
}
81
+
}
82
+
}
+99
lexicons/repo/languages.json
+99
lexicons/repo/languages.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.languages",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)",
18
+
"default": "HEAD"
19
+
}
20
+
}
21
+
},
22
+
"output": {
23
+
"encoding": "application/json",
24
+
"schema": {
25
+
"type": "object",
26
+
"required": ["ref", "languages"],
27
+
"properties": {
28
+
"ref": {
29
+
"type": "string",
30
+
"description": "The git reference used"
31
+
},
32
+
"languages": {
33
+
"type": "array",
34
+
"items": {
35
+
"type": "ref",
36
+
"ref": "#language"
37
+
}
38
+
},
39
+
"totalSize": {
40
+
"type": "integer",
41
+
"description": "Total size of all analyzed files in bytes"
42
+
},
43
+
"totalFiles": {
44
+
"type": "integer",
45
+
"description": "Total number of files analyzed"
46
+
}
47
+
}
48
+
}
49
+
},
50
+
"errors": [
51
+
{
52
+
"name": "RepoNotFound",
53
+
"description": "Repository not found or access denied"
54
+
},
55
+
{
56
+
"name": "RefNotFound",
57
+
"description": "Git reference not found"
58
+
},
59
+
{
60
+
"name": "InvalidRequest",
61
+
"description": "Invalid request parameters"
62
+
}
63
+
]
64
+
},
65
+
"language": {
66
+
"type": "object",
67
+
"required": ["name", "size", "percentage"],
68
+
"properties": {
69
+
"name": {
70
+
"type": "string",
71
+
"description": "Programming language name"
72
+
},
73
+
"size": {
74
+
"type": "integer",
75
+
"description": "Total size of files in this language (bytes)"
76
+
},
77
+
"percentage": {
78
+
"type": "integer",
79
+
"description": "Percentage of total codebase (0-100)"
80
+
},
81
+
"fileCount": {
82
+
"type": "integer",
83
+
"description": "Number of files in this language"
84
+
},
85
+
"color": {
86
+
"type": "string",
87
+
"description": "Hex color code for this language"
88
+
},
89
+
"extensions": {
90
+
"type": "array",
91
+
"items": {
92
+
"type": "string"
93
+
},
94
+
"description": "File extensions associated with this language"
95
+
}
96
+
}
97
+
}
98
+
}
99
+
}
+60
lexicons/repo/log.json
+60
lexicons/repo/log.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.log",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "ref"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)"
18
+
},
19
+
"path": {
20
+
"type": "string",
21
+
"description": "Path to filter commits by",
22
+
"default": ""
23
+
},
24
+
"limit": {
25
+
"type": "integer",
26
+
"description": "Maximum number of commits to return",
27
+
"minimum": 1,
28
+
"maximum": 100,
29
+
"default": 50
30
+
},
31
+
"cursor": {
32
+
"type": "string",
33
+
"description": "Pagination cursor (commit SHA)"
34
+
}
35
+
}
36
+
},
37
+
"output": {
38
+
"encoding": "*/*"
39
+
},
40
+
"errors": [
41
+
{
42
+
"name": "RepoNotFound",
43
+
"description": "Repository not found or access denied"
44
+
},
45
+
{
46
+
"name": "RefNotFound",
47
+
"description": "Git reference not found"
48
+
},
49
+
{
50
+
"name": "PathNotFound",
51
+
"description": "Path not found in repository"
52
+
},
53
+
{
54
+
"name": "InvalidRequest",
55
+
"description": "Invalid request parameters"
56
+
}
57
+
]
58
+
}
59
+
}
60
+
}
-1
lexicons/repo/repo.json
-1
lexicons/repo/repo.json
+123
lexicons/repo/tree.json
+123
lexicons/repo/tree.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.tree",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "ref"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)"
18
+
},
19
+
"path": {
20
+
"type": "string",
21
+
"description": "Path within the repository tree",
22
+
"default": ""
23
+
}
24
+
}
25
+
},
26
+
"output": {
27
+
"encoding": "application/json",
28
+
"schema": {
29
+
"type": "object",
30
+
"required": ["ref", "files"],
31
+
"properties": {
32
+
"ref": {
33
+
"type": "string",
34
+
"description": "The git reference used"
35
+
},
36
+
"parent": {
37
+
"type": "string",
38
+
"description": "The parent path in the tree"
39
+
},
40
+
"dotdot": {
41
+
"type": "string",
42
+
"description": "Parent directory path"
43
+
},
44
+
"files": {
45
+
"type": "array",
46
+
"items": {
47
+
"type": "ref",
48
+
"ref": "#treeEntry"
49
+
}
50
+
}
51
+
}
52
+
}
53
+
},
54
+
"errors": [
55
+
{
56
+
"name": "RepoNotFound",
57
+
"description": "Repository not found or access denied"
58
+
},
59
+
{
60
+
"name": "RefNotFound",
61
+
"description": "Git reference not found"
62
+
},
63
+
{
64
+
"name": "PathNotFound",
65
+
"description": "Path not found in repository tree"
66
+
},
67
+
{
68
+
"name": "InvalidRequest",
69
+
"description": "Invalid request parameters"
70
+
}
71
+
]
72
+
},
73
+
"treeEntry": {
74
+
"type": "object",
75
+
"required": ["name", "mode", "size", "is_file", "is_subtree"],
76
+
"properties": {
77
+
"name": {
78
+
"type": "string",
79
+
"description": "Relative file or directory name"
80
+
},
81
+
"mode": {
82
+
"type": "string",
83
+
"description": "File mode"
84
+
},
85
+
"size": {
86
+
"type": "integer",
87
+
"description": "File size in bytes"
88
+
},
89
+
"is_file": {
90
+
"type": "boolean",
91
+
"description": "Whether this entry is a file"
92
+
},
93
+
"is_subtree": {
94
+
"type": "boolean",
95
+
"description": "Whether this entry is a directory/subtree"
96
+
},
97
+
"last_commit": {
98
+
"type": "ref",
99
+
"ref": "#lastCommit"
100
+
}
101
+
}
102
+
},
103
+
"lastCommit": {
104
+
"type": "object",
105
+
"required": ["hash", "message", "when"],
106
+
"properties": {
107
+
"hash": {
108
+
"type": "string",
109
+
"description": "Commit hash"
110
+
},
111
+
"message": {
112
+
"type": "string",
113
+
"description": "Commit message"
114
+
},
115
+
"when": {
116
+
"type": "string",
117
+
"format": "datetime",
118
+
"description": "Commit timestamp"
119
+
}
120
+
}
121
+
}
122
+
}
123
+
}
+8
-2
nix/gomod2nix.toml
+8
-2
nix/gomod2nix.toml
···
425
425
[mod."github.com/whyrusleeping/cbor-gen"]
426
426
version = "v0.3.1"
427
427
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
428
+
[mod."github.com/wyatt915/goldmark-treeblood"]
429
+
version = "v0.0.0-20250825231212-5dcbdb2f4b57"
430
+
hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM="
431
+
[mod."github.com/wyatt915/treeblood"]
432
+
version = "v0.1.15"
433
+
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
428
434
[mod."github.com/yuin/goldmark"]
429
-
version = "v1.4.15"
430
-
hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0="
435
+
version = "v1.7.12"
436
+
hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM="
431
437
[mod."github.com/yuin/goldmark-highlighting/v2"]
432
438
version = "v2.0.0-20230729083705-37449abec8cc"
433
439
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+17
-12
nix/pkgs/knot-unwrapped.nix
+17
-12
nix/pkgs/knot-unwrapped.nix
···
3
3
modules,
4
4
sqlite-lib,
5
5
src,
6
-
}:
7
-
buildGoApplication {
8
-
pname = "knot";
9
-
version = "0.1.0";
10
-
inherit src modules;
6
+
}: let
7
+
version = "1.9.0-alpha";
8
+
in
9
+
buildGoApplication {
10
+
pname = "knot";
11
+
inherit src version modules;
12
+
13
+
doCheck = false;
11
14
12
-
doCheck = false;
15
+
subPackages = ["cmd/knot"];
16
+
tags = ["libsqlite3"];
13
17
14
-
subPackages = ["cmd/knot"];
15
-
tags = ["libsqlite3"];
18
+
ldflags = [
19
+
"-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}"
20
+
];
16
21
17
-
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
18
-
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
19
-
CGO_ENABLED = 1;
20
-
}
22
+
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
23
+
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
24
+
CGO_ENABLED = 1;
25
+
}
+1
-1
patchutil/combinediff.go
+1
-1
patchutil/combinediff.go
-3
spindle/server.go
-3
spindle/server.go
···
203
203
w.Write(motd)
204
204
})
205
205
mux.HandleFunc("/events", s.Events)
206
-
mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) {
207
-
w.Write([]byte(s.cfg.Server.Owner))
208
-
})
209
206
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
210
207
211
208
mux.Mount("/xrpc", s.XrpcRouter())
+31
spindle/xrpc/owner.go
+31
spindle/xrpc/owner.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
7
+
"tangled.sh/tangled.sh/core/api/tangled"
8
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
9
+
)
10
+
11
+
func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
12
+
owner := x.Config.Server.Owner
13
+
if owner == "" {
14
+
writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError)
15
+
return
16
+
}
17
+
18
+
response := tangled.Owner_Output{
19
+
Owner: owner,
20
+
}
21
+
22
+
w.Header().Set("Content-Type", "application/json")
23
+
if err := json.NewEncoder(w).Encode(response); err != nil {
24
+
x.Logger.Error("failed to encode response", "error", err)
25
+
writeError(w, xrpcerr.NewXrpcError(
26
+
xrpcerr.WithTag("InternalServerError"),
27
+
xrpcerr.WithMessage("failed to encode response"),
28
+
), http.StatusInternalServerError)
29
+
return
30
+
}
31
+
}
+10
-3
spindle/xrpc/xrpc.go
+10
-3
spindle/xrpc/xrpc.go
···
35
35
func (x *Xrpc) Router() http.Handler {
36
36
r := chi.NewRouter()
37
37
38
-
r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
39
-
r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
40
-
r.With(x.ServiceAuth.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
38
+
r.Group(func(r chi.Router) {
39
+
r.Use(x.ServiceAuth.VerifyServiceAuth)
40
+
41
+
r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
42
+
r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
43
+
r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
44
+
})
45
+
46
+
// service query endpoints (no auth required)
47
+
r.Get("/"+tangled.OwnerNSID, x.Owner)
41
48
42
49
return r
43
50
}
+5
xrpc/errors/errors.go
+5
xrpc/errors/errors.go
···
51
51
WithMessage("actor DID not supplied"),
52
52
)
53
53
54
+
var OwnerNotFoundError = NewXrpcError(
55
+
WithTag("OwnerNotFound"),
56
+
WithMessage("owner not set for this service"),
57
+
)
58
+
54
59
var AuthError = func(err error) XrpcError {
55
60
return NewXrpcError(
56
61
WithTag("Auth"),