-13
.editorconfig
-13
.editorconfig
-1
.gitignore
-1
.gitignore
+1
-1
.tangled/workflows/build.yml
+1
-1
.tangled/workflows/build.yml
+1
-1
.tangled/workflows/fmt.yml
+1
-1
.tangled/workflows/fmt.yml
+1
-1
.tangled/workflows/test.yml
+1
-1
.tangled/workflows/test.yml
+1
-3
api/tangled/actorprofile.go
+1
-3
api/tangled/actorprofile.go
···
27
27
Location *string `json:"location,omitempty" cborgen:"location,omitempty"`
28
28
// pinnedRepositories: Any ATURI, it is up to appviews to validate these fields.
29
29
PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"`
30
-
// pronouns: Preferred gender pronouns.
31
-
Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"`
32
-
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
30
+
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
33
31
}
+2
-196
api/tangled/cbor_gen.go
+2
-196
api/tangled/cbor_gen.go
···
26
26
}
27
27
28
28
cw := cbg.NewCborWriter(w)
29
-
fieldCount := 8
29
+
fieldCount := 7
30
30
31
31
if t.Description == nil {
32
32
fieldCount--
···
41
41
}
42
42
43
43
if t.PinnedRepositories == nil {
44
-
fieldCount--
45
-
}
46
-
47
-
if t.Pronouns == nil {
48
44
fieldCount--
49
45
}
50
46
···
190
186
return err
191
187
}
192
188
if _, err := cw.WriteString(string(*t.Location)); err != nil {
193
-
return err
194
-
}
195
-
}
196
-
}
197
-
198
-
// t.Pronouns (string) (string)
199
-
if t.Pronouns != nil {
200
-
201
-
if len("pronouns") > 1000000 {
202
-
return xerrors.Errorf("Value in field \"pronouns\" was too long")
203
-
}
204
-
205
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pronouns"))); err != nil {
206
-
return err
207
-
}
208
-
if _, err := cw.WriteString(string("pronouns")); err != nil {
209
-
return err
210
-
}
211
-
212
-
if t.Pronouns == nil {
213
-
if _, err := cw.Write(cbg.CborNull); err != nil {
214
-
return err
215
-
}
216
-
} else {
217
-
if len(*t.Pronouns) > 1000000 {
218
-
return xerrors.Errorf("Value in field t.Pronouns was too long")
219
-
}
220
-
221
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Pronouns))); err != nil {
222
-
return err
223
-
}
224
-
if _, err := cw.WriteString(string(*t.Pronouns)); err != nil {
225
189
return err
226
190
}
227
191
}
···
466
430
}
467
431
468
432
t.Location = (*string)(&sval)
469
-
}
470
-
}
471
-
// t.Pronouns (string) (string)
472
-
case "pronouns":
473
-
474
-
{
475
-
b, err := cr.ReadByte()
476
-
if err != nil {
477
-
return err
478
-
}
479
-
if b != cbg.CborNull[0] {
480
-
if err := cr.UnreadByte(); err != nil {
481
-
return err
482
-
}
483
-
484
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
485
-
if err != nil {
486
-
return err
487
-
}
488
-
489
-
t.Pronouns = (*string)(&sval)
490
433
}
491
434
}
492
435
// t.Description (string) (string)
···
5863
5806
}
5864
5807
5865
5808
cw := cbg.NewCborWriter(w)
5866
-
fieldCount := 10
5809
+
fieldCount := 8
5867
5810
5868
5811
if t.Description == nil {
5869
5812
fieldCount--
···
5878
5821
}
5879
5822
5880
5823
if t.Spindle == nil {
5881
-
fieldCount--
5882
-
}
5883
-
5884
-
if t.Topics == nil {
5885
-
fieldCount--
5886
-
}
5887
-
5888
-
if t.Website == nil {
5889
5824
fieldCount--
5890
5825
}
5891
5826
···
6026
5961
}
6027
5962
}
6028
5963
6029
-
// t.Topics ([]string) (slice)
6030
-
if t.Topics != nil {
6031
-
6032
-
if len("topics") > 1000000 {
6033
-
return xerrors.Errorf("Value in field \"topics\" was too long")
6034
-
}
6035
-
6036
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("topics"))); err != nil {
6037
-
return err
6038
-
}
6039
-
if _, err := cw.WriteString(string("topics")); err != nil {
6040
-
return err
6041
-
}
6042
-
6043
-
if len(t.Topics) > 8192 {
6044
-
return xerrors.Errorf("Slice value in field t.Topics was too long")
6045
-
}
6046
-
6047
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Topics))); err != nil {
6048
-
return err
6049
-
}
6050
-
for _, v := range t.Topics {
6051
-
if len(v) > 1000000 {
6052
-
return xerrors.Errorf("Value in field v was too long")
6053
-
}
6054
-
6055
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
6056
-
return err
6057
-
}
6058
-
if _, err := cw.WriteString(string(v)); err != nil {
6059
-
return err
6060
-
}
6061
-
6062
-
}
6063
-
}
6064
-
6065
5964
// t.Spindle (string) (string)
6066
5965
if t.Spindle != nil {
6067
5966
···
6094
5993
}
6095
5994
}
6096
5995
6097
-
// t.Website (string) (string)
6098
-
if t.Website != nil {
6099
-
6100
-
if len("website") > 1000000 {
6101
-
return xerrors.Errorf("Value in field \"website\" was too long")
6102
-
}
6103
-
6104
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("website"))); err != nil {
6105
-
return err
6106
-
}
6107
-
if _, err := cw.WriteString(string("website")); err != nil {
6108
-
return err
6109
-
}
6110
-
6111
-
if t.Website == nil {
6112
-
if _, err := cw.Write(cbg.CborNull); err != nil {
6113
-
return err
6114
-
}
6115
-
} else {
6116
-
if len(*t.Website) > 1000000 {
6117
-
return xerrors.Errorf("Value in field t.Website was too long")
6118
-
}
6119
-
6120
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Website))); err != nil {
6121
-
return err
6122
-
}
6123
-
if _, err := cw.WriteString(string(*t.Website)); err != nil {
6124
-
return err
6125
-
}
6126
-
}
6127
-
}
6128
-
6129
5996
// t.CreatedAt (string) (string)
6130
5997
if len("createdAt") > 1000000 {
6131
5998
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6318
6185
t.Source = (*string)(&sval)
6319
6186
}
6320
6187
}
6321
-
// t.Topics ([]string) (slice)
6322
-
case "topics":
6323
-
6324
-
maj, extra, err = cr.ReadHeader()
6325
-
if err != nil {
6326
-
return err
6327
-
}
6328
-
6329
-
if extra > 8192 {
6330
-
return fmt.Errorf("t.Topics: array too large (%d)", extra)
6331
-
}
6332
-
6333
-
if maj != cbg.MajArray {
6334
-
return fmt.Errorf("expected cbor array")
6335
-
}
6336
-
6337
-
if extra > 0 {
6338
-
t.Topics = make([]string, extra)
6339
-
}
6340
-
6341
-
for i := 0; i < int(extra); i++ {
6342
-
{
6343
-
var maj byte
6344
-
var extra uint64
6345
-
var err error
6346
-
_ = maj
6347
-
_ = extra
6348
-
_ = err
6349
-
6350
-
{
6351
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6352
-
if err != nil {
6353
-
return err
6354
-
}
6355
-
6356
-
t.Topics[i] = string(sval)
6357
-
}
6358
-
6359
-
}
6360
-
}
6361
6188
// t.Spindle (string) (string)
6362
6189
case "spindle":
6363
6190
···
6377
6204
}
6378
6205
6379
6206
t.Spindle = (*string)(&sval)
6380
-
}
6381
-
}
6382
-
// t.Website (string) (string)
6383
-
case "website":
6384
-
6385
-
{
6386
-
b, err := cr.ReadByte()
6387
-
if err != nil {
6388
-
return err
6389
-
}
6390
-
if b != cbg.CborNull[0] {
6391
-
if err := cr.UnreadByte(); err != nil {
6392
-
return err
6393
-
}
6394
-
6395
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6396
-
if err != nil {
6397
-
return err
6398
-
}
6399
-
6400
-
t.Website = (*string)(&sval)
6401
6207
}
6402
6208
}
6403
6209
// t.CreatedAt (string) (string)
+1
-13
api/tangled/repoblob.go
+1
-13
api/tangled/repoblob.go
···
30
30
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
31
31
type RepoBlob_Output struct {
32
32
// content: File content (base64 encoded for binary files)
33
-
Content *string `json:"content,omitempty" cborgen:"content,omitempty"`
33
+
Content string `json:"content" cborgen:"content"`
34
34
// encoding: Content encoding
35
35
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
36
36
// isBinary: Whether the file is binary
···
44
44
Ref string `json:"ref" cborgen:"ref"`
45
45
// size: File size in bytes
46
46
Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"`
47
-
// submodule: Submodule information if path is a submodule
48
-
Submodule *RepoBlob_Submodule `json:"submodule,omitempty" cborgen:"submodule,omitempty"`
49
47
}
50
48
51
49
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
···
56
54
Name string `json:"name" cborgen:"name"`
57
55
// when: Author timestamp
58
56
When string `json:"when" cborgen:"when"`
59
-
}
60
-
61
-
// RepoBlob_Submodule is a "submodule" in the sh.tangled.repo.blob schema.
62
-
type RepoBlob_Submodule struct {
63
-
// branch: Branch to track in the submodule
64
-
Branch *string `json:"branch,omitempty" cborgen:"branch,omitempty"`
65
-
// name: Submodule name
66
-
Name string `json:"name" cborgen:"name"`
67
-
// url: Submodule repository URL
68
-
Url string `json:"url" cborgen:"url"`
69
57
}
70
58
71
59
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
-30
api/tangled/repodeleteBranch.go
-30
api/tangled/repodeleteBranch.go
···
1
-
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
-
3
-
package tangled
4
-
5
-
// schema: sh.tangled.repo.deleteBranch
6
-
7
-
import (
8
-
"context"
9
-
10
-
"github.com/bluesky-social/indigo/lex/util"
11
-
)
12
-
13
-
const (
14
-
RepoDeleteBranchNSID = "sh.tangled.repo.deleteBranch"
15
-
)
16
-
17
-
// RepoDeleteBranch_Input is the input argument to a sh.tangled.repo.deleteBranch call.
18
-
type RepoDeleteBranch_Input struct {
19
-
Branch string `json:"branch" cborgen:"branch"`
20
-
Repo string `json:"repo" cborgen:"repo"`
21
-
}
22
-
23
-
// RepoDeleteBranch calls the XRPC method "sh.tangled.repo.deleteBranch".
24
-
func RepoDeleteBranch(ctx context.Context, c util.LexClient, input *RepoDeleteBranch_Input) error {
25
-
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.deleteBranch", nil, input, nil); err != nil {
26
-
return err
27
-
}
28
-
29
-
return nil
30
-
}
+4
api/tangled/repotree.go
+4
api/tangled/repotree.go
···
47
47
48
48
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
49
49
type RepoTree_TreeEntry struct {
50
+
// is_file: Whether this entry is a file
51
+
Is_file bool `json:"is_file" cborgen:"is_file"`
52
+
// is_subtree: Whether this entry is a directory/subtree
53
+
Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"`
50
54
Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"`
51
55
// mode: File mode
52
56
Mode string `json:"mode" cborgen:"mode"`
-4
api/tangled/tangledrepo.go
-4
api/tangled/tangledrepo.go
···
30
30
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
31
31
// spindle: CI runner to send jobs to and receive results from
32
32
Spindle *string `json:"spindle,omitempty" cborgen:"spindle,omitempty"`
33
-
// topics: Topics related to the repo
34
-
Topics []string `json:"topics,omitempty" cborgen:"topics,omitempty"`
35
-
// website: Any URI related to the repo
36
-
Website *string `json:"website,omitempty" cborgen:"website,omitempty"`
37
33
}
+2
-15
appview/config/config.go
+2
-15
appview/config/config.go
···
13
13
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
14
14
DbPath string `env:"DB_PATH, default=appview.db"`
15
15
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
16
-
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.org"`
17
-
AppviewName string `env:"APPVIEW_Name, default=Tangled"`
16
+
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
18
17
Dev bool `env:"DEV, default=false"`
19
18
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
20
19
···
26
25
}
27
26
28
27
type OAuthConfig struct {
29
-
ClientSecret string `env:"CLIENT_SECRET"`
30
-
ClientKid string `env:"CLIENT_KID"`
31
-
}
32
-
33
-
type PlcConfig struct {
34
-
PLCURL string `env:"URL, default=https://plc.directory"`
28
+
Jwks string `env:"JWKS"`
35
29
}
36
30
37
31
type JetstreamConfig struct {
···
84
78
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
85
79
}
86
80
87
-
type LabelConfig struct {
88
-
DefaultLabelDefs []string `env:"DEFAULTS, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"` // delimiter=,
89
-
GoodFirstIssue string `env:"GFI, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"`
90
-
}
91
-
92
81
func (cfg RedisConfig) ToURL() string {
93
82
u := &url.URL{
94
83
Scheme: "redis",
···
114
103
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
115
104
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
116
105
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
117
-
Plc PlcConfig `env:",prefix=TANGLED_PLC_"`
118
106
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
119
107
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
120
-
Label LabelConfig `env:",prefix=TANGLED_LABEL_"`
121
108
}
122
109
123
110
func LoadConfig(ctx context.Context) (*Config, error) {
+1
appview/db/artifact.go
+1
appview/db/artifact.go
-53
appview/db/collaborators.go
-53
appview/db/collaborators.go
···
3
3
import (
4
4
"fmt"
5
5
"strings"
6
-
"time"
7
6
8
7
"tangled.org/core/appview/models"
9
8
)
···
60
59
61
60
return GetRepos(e, 0, FilterIn("at_uri", repoAts))
62
61
}
63
-
64
-
func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) {
65
-
var collaborators []models.Collaborator
66
-
var conditions []string
67
-
var args []any
68
-
for _, filter := range filters {
69
-
conditions = append(conditions, filter.Condition())
70
-
args = append(args, filter.Arg()...)
71
-
}
72
-
whereClause := ""
73
-
if conditions != nil {
74
-
whereClause = " where " + strings.Join(conditions, " and ")
75
-
}
76
-
query := fmt.Sprintf(`select
77
-
id,
78
-
did,
79
-
rkey,
80
-
subject_did,
81
-
repo_at,
82
-
created
83
-
from collaborators %s`,
84
-
whereClause,
85
-
)
86
-
rows, err := e.Query(query, args...)
87
-
if err != nil {
88
-
return nil, err
89
-
}
90
-
defer rows.Close()
91
-
for rows.Next() {
92
-
var collaborator models.Collaborator
93
-
var createdAt string
94
-
if err := rows.Scan(
95
-
&collaborator.Id,
96
-
&collaborator.Did,
97
-
&collaborator.Rkey,
98
-
&collaborator.SubjectDid,
99
-
&collaborator.RepoAt,
100
-
&createdAt,
101
-
); err != nil {
102
-
return nil, err
103
-
}
104
-
collaborator.Created, err = time.Parse(time.RFC3339, createdAt)
105
-
if err != nil {
106
-
collaborator.Created = time.Now()
107
-
}
108
-
collaborators = append(collaborators, collaborator)
109
-
}
110
-
if err := rows.Err(); err != nil {
111
-
return nil, err
112
-
}
113
-
return collaborators, nil
114
-
}
+26
-205
appview/db/db.go
+26
-205
appview/db/db.go
···
4
4
"context"
5
5
"database/sql"
6
6
"fmt"
7
-
"log/slog"
7
+
"log"
8
8
"reflect"
9
9
"strings"
10
10
11
11
_ "github.com/mattn/go-sqlite3"
12
-
"tangled.org/core/log"
13
12
)
14
13
15
14
type DB struct {
16
15
*sql.DB
17
-
logger *slog.Logger
18
16
}
19
17
20
18
type Execer interface {
···
28
26
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
29
27
}
30
28
31
-
func Make(ctx context.Context, dbPath string) (*DB, error) {
29
+
func Make(dbPath string) (*DB, error) {
32
30
// https://github.com/mattn/go-sqlite3#connection-string
33
31
opts := []string{
34
32
"_foreign_keys=1",
···
37
35
"_auto_vacuum=incremental",
38
36
}
39
37
40
-
logger := log.FromContext(ctx)
41
-
logger = log.SubLogger(logger, "db")
42
-
43
38
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
44
39
if err != nil {
45
40
return nil, err
46
41
}
42
+
43
+
ctx := context.Background()
47
44
48
45
conn, err := db.Conn(ctx)
49
46
if err != nil {
···
577
574
}
578
575
579
576
// run migrations
580
-
runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error {
577
+
runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error {
581
578
tx.Exec(`
582
579
alter table repos add column description text check (length(description) <= 200);
583
580
`)
584
581
return nil
585
582
})
586
583
587
-
runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
584
+
runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
588
585
// add unconstrained column
589
586
_, err := tx.Exec(`
590
587
alter table public_keys
···
607
604
return nil
608
605
})
609
606
610
-
runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error {
607
+
runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error {
611
608
_, err := tx.Exec(`
612
609
alter table comments drop column comment_at;
613
610
alter table comments add column rkey text;
···
615
612
return err
616
613
})
617
614
618
-
runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
615
+
runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
619
616
_, err := tx.Exec(`
620
617
alter table comments add column deleted text; -- timestamp
621
618
alter table comments add column edited text; -- timestamp
···
623
620
return err
624
621
})
625
622
626
-
runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
623
+
runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
627
624
_, err := tx.Exec(`
628
625
alter table pulls add column source_branch text;
629
626
alter table pulls add column source_repo_at text;
···
632
629
return err
633
630
})
634
631
635
-
runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error {
632
+
runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error {
636
633
_, err := tx.Exec(`
637
634
alter table repos add column source text;
638
635
`)
···
644
641
//
645
642
// [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
646
643
conn.ExecContext(ctx, "pragma foreign_keys = off;")
647
-
runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
644
+
runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
648
645
_, err := tx.Exec(`
649
646
create table pulls_new (
650
647
-- identifiers
···
701
698
})
702
699
conn.ExecContext(ctx, "pragma foreign_keys = on;")
703
700
704
-
runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error {
701
+
runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error {
705
702
tx.Exec(`
706
703
alter table repos add column spindle text;
707
704
`)
···
711
708
// drop all knot secrets, add unique constraint to knots
712
709
//
713
710
// knots will henceforth use service auth for signed requests
714
-
runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error {
711
+
runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error {
715
712
_, err := tx.Exec(`
716
713
create table registrations_new (
717
714
id integer primary key autoincrement,
···
734
731
})
735
732
736
733
// recreate and add rkey + created columns with default constraint
737
-
runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error {
734
+
runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error {
738
735
// create new table
739
736
// - repo_at instead of repo integer
740
737
// - rkey field
···
788
785
return err
789
786
})
790
787
791
-
runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error {
788
+
runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error {
792
789
_, err := tx.Exec(`
793
790
alter table issues add column rkey text not null default '';
794
791
···
800
797
})
801
798
802
799
// repurpose the read-only column to "needs-upgrade"
803
-
runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
800
+
runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
804
801
_, err := tx.Exec(`
805
802
alter table registrations rename column read_only to needs_upgrade;
806
803
`)
···
808
805
})
809
806
810
807
// require all knots to upgrade after the release of total xrpc
811
-
runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
808
+
runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
812
809
_, err := tx.Exec(`
813
810
update registrations set needs_upgrade = 1;
814
811
`)
···
816
813
})
817
814
818
815
// require all knots to upgrade after the release of total xrpc
819
-
runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
816
+
runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
820
817
_, err := tx.Exec(`
821
818
alter table spindles add column needs_upgrade integer not null default 0;
822
819
`)
···
834
831
//
835
832
// disable foreign-keys for the next migration
836
833
conn.ExecContext(ctx, "pragma foreign_keys = off;")
837
-
runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
834
+
runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
838
835
_, err := tx.Exec(`
839
836
create table if not exists issues_new (
840
837
-- identifiers
···
904
901
// - new columns
905
902
// * column "reply_to" which can be any other comment
906
903
// * column "at-uri" which is a generated column
907
-
runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error {
904
+
runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error {
908
905
_, err := tx.Exec(`
909
906
create table if not exists issue_comments (
910
907
-- identifiers
···
957
954
return err
958
955
})
959
956
960
-
// add generated at_uri column to pulls table
961
-
//
962
-
// this requires a full table recreation because stored columns
963
-
// cannot be added via alter
964
-
//
965
-
// disable foreign-keys for the next migration
966
-
conn.ExecContext(ctx, "pragma foreign_keys = off;")
967
-
runMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
968
-
_, err := tx.Exec(`
969
-
create table if not exists pulls_new (
970
-
-- identifiers
971
-
id integer primary key autoincrement,
972
-
pull_id integer not null,
973
-
at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored,
974
-
975
-
-- at identifiers
976
-
repo_at text not null,
977
-
owner_did text not null,
978
-
rkey text not null,
979
-
980
-
-- content
981
-
title text not null,
982
-
body text not null,
983
-
target_branch text not null,
984
-
state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
985
-
986
-
-- source info
987
-
source_branch text,
988
-
source_repo_at text,
989
-
990
-
-- stacking
991
-
stack_id text,
992
-
change_id text,
993
-
parent_change_id text,
994
-
995
-
-- meta
996
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
997
-
998
-
-- constraints
999
-
unique(repo_at, pull_id),
1000
-
unique(at_uri),
1001
-
foreign key (repo_at) references repos(at_uri) on delete cascade
1002
-
);
1003
-
`)
1004
-
if err != nil {
1005
-
return err
1006
-
}
1007
-
1008
-
// transfer data
1009
-
_, err = tx.Exec(`
1010
-
insert into pulls_new (
1011
-
id, pull_id, repo_at, owner_did, rkey,
1012
-
title, body, target_branch, state,
1013
-
source_branch, source_repo_at,
1014
-
stack_id, change_id, parent_change_id,
1015
-
created
1016
-
)
1017
-
select
1018
-
id, pull_id, repo_at, owner_did, rkey,
1019
-
title, body, target_branch, state,
1020
-
source_branch, source_repo_at,
1021
-
stack_id, change_id, parent_change_id,
1022
-
created
1023
-
from pulls;
1024
-
`)
1025
-
if err != nil {
1026
-
return err
1027
-
}
1028
-
1029
-
// drop old table
1030
-
_, err = tx.Exec(`drop table pulls`)
1031
-
if err != nil {
1032
-
return err
1033
-
}
1034
-
1035
-
// rename new table
1036
-
_, err = tx.Exec(`alter table pulls_new rename to pulls`)
1037
-
return err
1038
-
})
1039
-
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1040
-
1041
-
// remove repo_at and pull_id from pull_submissions and replace with pull_at
1042
-
//
1043
-
// this requires a full table recreation because stored columns
1044
-
// cannot be added via alter
1045
-
//
1046
-
// disable foreign-keys for the next migration
1047
-
conn.ExecContext(ctx, "pragma foreign_keys = off;")
1048
-
runMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1049
-
_, err := tx.Exec(`
1050
-
create table if not exists pull_submissions_new (
1051
-
-- identifiers
1052
-
id integer primary key autoincrement,
1053
-
pull_at text not null,
1054
-
1055
-
-- content, these are immutable, and require a resubmission to update
1056
-
round_number integer not null default 0,
1057
-
patch text,
1058
-
source_rev text,
1059
-
1060
-
-- meta
1061
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1062
-
1063
-
-- constraints
1064
-
unique(pull_at, round_number),
1065
-
foreign key (pull_at) references pulls(at_uri) on delete cascade
1066
-
);
1067
-
`)
1068
-
if err != nil {
1069
-
return err
1070
-
}
1071
-
1072
-
// transfer data, constructing pull_at from pulls table
1073
-
_, err = tx.Exec(`
1074
-
insert into pull_submissions_new (id, pull_at, round_number, patch, created)
1075
-
select
1076
-
ps.id,
1077
-
'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey,
1078
-
ps.round_number,
1079
-
ps.patch,
1080
-
ps.created
1081
-
from pull_submissions ps
1082
-
join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id;
1083
-
`)
1084
-
if err != nil {
1085
-
return err
1086
-
}
1087
-
1088
-
// drop old table
1089
-
_, err = tx.Exec(`drop table pull_submissions`)
1090
-
if err != nil {
1091
-
return err
1092
-
}
1093
-
1094
-
// rename new table
1095
-
_, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`)
1096
-
return err
1097
-
})
1098
-
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1099
-
1100
-
// knots may report the combined patch for a comparison, we can store that on the appview side
1101
-
// (but not on the pds record), because calculating the combined patch requires a git index
1102
-
runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
1103
-
_, err := tx.Exec(`
1104
-
alter table pull_submissions add column combined text;
1105
-
`)
1106
-
return err
1107
-
})
1108
-
1109
-
runMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error {
1110
-
_, err := tx.Exec(`
1111
-
alter table profile add column pronouns text;
1112
-
`)
1113
-
return err
1114
-
})
1115
-
1116
-
runMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error {
1117
-
_, err := tx.Exec(`
1118
-
alter table repos add column website text;
1119
-
alter table repos add column topics text;
1120
-
`)
1121
-
return err
1122
-
})
1123
-
1124
-
runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error {
1125
-
_, err := tx.Exec(`
1126
-
alter table notification_preferences add column user_mentioned integer not null default 1;
1127
-
`)
1128
-
return err
1129
-
})
1130
-
1131
-
return &DB{
1132
-
db,
1133
-
logger,
1134
-
}, nil
957
+
return &DB{db}, nil
1135
958
}
1136
959
1137
960
type migrationFn = func(*sql.Tx) error
1138
961
1139
-
func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error {
1140
-
logger = logger.With("migration", name)
1141
-
962
+
func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error {
1142
963
tx, err := c.BeginTx(context.Background(), nil)
1143
964
if err != nil {
1144
965
return err
···
1155
976
// run migration
1156
977
err = migrationFn(tx)
1157
978
if err != nil {
1158
-
logger.Error("failed to run migration", "err", err)
979
+
log.Printf("Failed to run migration %s: %v", name, err)
1159
980
return err
1160
981
}
1161
982
1162
983
// mark migration as complete
1163
984
_, err = tx.Exec("insert into migrations (name) values (?)", name)
1164
985
if err != nil {
1165
-
logger.Error("failed to mark migration as complete", "err", err)
986
+
log.Printf("Failed to mark migration %s as complete: %v", name, err)
1166
987
return err
1167
988
}
1168
989
···
1171
992
return err
1172
993
}
1173
994
1174
-
logger.Info("migration applied successfully")
995
+
log.Printf("migration %s applied successfully", name)
1175
996
} else {
1176
-
logger.Warn("skipped migration, already applied")
997
+
log.Printf("skipped migration %s, already applied", name)
1177
998
}
1178
999
1179
1000
return nil
+9
-13
appview/db/email.go
+9
-13
appview/db/email.go
···
71
71
return did, nil
72
72
}
73
73
74
-
func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) {
75
-
if len(emails) == 0 {
74
+
func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) {
75
+
if len(ems) == 0 {
76
76
return make(map[string]string), nil
77
77
}
78
78
···
80
80
if isVerifiedFilter {
81
81
verifiedFilter = 1
82
82
}
83
-
84
-
assoc := make(map[string]string)
85
83
86
84
// Create placeholders for the IN clause
87
-
placeholders := make([]string, 0, len(emails))
88
-
args := make([]any, 1, len(emails)+1)
85
+
placeholders := make([]string, len(ems))
86
+
args := make([]any, len(ems)+1)
89
87
90
88
args[0] = verifiedFilter
91
-
for _, email := range emails {
92
-
if strings.HasPrefix(email, "did:") {
93
-
assoc[email] = email
94
-
continue
95
-
}
96
-
placeholders = append(placeholders, "?")
97
-
args = append(args, email)
89
+
for i, em := range ems {
90
+
placeholders[i] = "?"
91
+
args[i+1] = em
98
92
}
99
93
100
94
query := `
···
110
104
return nil, err
111
105
}
112
106
defer rows.Close()
107
+
108
+
assoc := make(map[string]string)
113
109
114
110
for rows.Next() {
115
111
var email, did string
+16
-72
appview/db/issues.go
+16
-72
appview/db/issues.go
···
101
101
pLower := FilterGte("row_num", page.Offset+1)
102
102
pUpper := FilterLte("row_num", page.Offset+page.Limit)
103
103
104
-
pageClause := ""
105
-
if page.Limit > 0 {
106
-
args = append(args, pLower.Arg()...)
107
-
args = append(args, pUpper.Arg()...)
108
-
pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition()
109
-
}
104
+
args = append(args, pLower.Arg()...)
105
+
args = append(args, pUpper.Arg()...)
106
+
pagination := " where " + pLower.Condition() + " and " + pUpper.Condition()
110
107
111
108
query := fmt.Sprintf(
112
109
`
···
131
128
%s
132
129
`,
133
130
whereClause,
134
-
pageClause,
131
+
pagination,
135
132
)
136
133
137
134
rows, err := e.Query(query, args...)
···
246
243
return issues, nil
247
244
}
248
245
249
-
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) {
250
-
issues, err := GetIssuesPaginated(
251
-
e,
252
-
pagination.Page{},
253
-
FilterEq("repo_at", repoAt),
254
-
FilterEq("issue_id", issueId),
255
-
)
256
-
if err != nil {
257
-
return nil, err
258
-
}
259
-
if len(issues) != 1 {
260
-
return nil, sql.ErrNoRows
261
-
}
262
-
263
-
return &issues[0], nil
264
-
}
265
-
266
246
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
267
-
return GetIssuesPaginated(e, pagination.Page{}, filters...)
247
+
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
268
248
}
269
249
270
-
// GetIssueIDs gets list of all existing issue's IDs
271
-
func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) {
272
-
var ids []int64
250
+
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) {
251
+
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
252
+
row := e.QueryRow(query, repoAt, issueId)
273
253
274
-
var filters []filter
275
-
openValue := 0
276
-
if opts.IsOpen {
277
-
openValue = 1
278
-
}
279
-
filters = append(filters, FilterEq("open", openValue))
280
-
if opts.RepoAt != "" {
281
-
filters = append(filters, FilterEq("repo_at", opts.RepoAt))
254
+
var issue models.Issue
255
+
var createdAt string
256
+
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
257
+
if err != nil {
258
+
return nil, err
282
259
}
283
260
284
-
var conditions []string
285
-
var args []any
286
-
287
-
for _, filter := range filters {
288
-
conditions = append(conditions, filter.Condition())
289
-
args = append(args, filter.Arg()...)
290
-
}
291
-
292
-
whereClause := ""
293
-
if conditions != nil {
294
-
whereClause = " where " + strings.Join(conditions, " and ")
295
-
}
296
-
query := fmt.Sprintf(
297
-
`
298
-
select
299
-
id
300
-
from
301
-
issues
302
-
%s
303
-
limit ? offset ?`,
304
-
whereClause,
305
-
)
306
-
args = append(args, opts.Page.Limit, opts.Page.Offset)
307
-
rows, err := e.Query(query, args...)
261
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
308
262
if err != nil {
309
263
return nil, err
310
264
}
311
-
defer rows.Close()
265
+
issue.Created = createdTime
312
266
313
-
for rows.Next() {
314
-
var id int64
315
-
err := rows.Scan(&id)
316
-
if err != nil {
317
-
return nil, err
318
-
}
319
-
320
-
ids = append(ids, id)
321
-
}
322
-
323
-
return ids, nil
267
+
return &issue, nil
324
268
}
325
269
326
270
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
-34
appview/db/language.go
-34
appview/db/language.go
···
1
1
package db
2
2
3
3
import (
4
-
"database/sql"
5
4
"fmt"
6
5
"strings"
7
6
8
-
"github.com/bluesky-social/indigo/atproto/syntax"
9
7
"tangled.org/core/appview/models"
10
8
)
11
9
···
84
82
85
83
return nil
86
84
}
87
-
88
-
func DeleteRepoLanguages(e Execer, filters ...filter) error {
89
-
var conditions []string
90
-
var args []any
91
-
for _, filter := range filters {
92
-
conditions = append(conditions, filter.Condition())
93
-
args = append(args, filter.Arg()...)
94
-
}
95
-
96
-
whereClause := ""
97
-
if conditions != nil {
98
-
whereClause = " where " + strings.Join(conditions, " and ")
99
-
}
100
-
101
-
query := fmt.Sprintf(`delete from repo_languages %s`, whereClause)
102
-
103
-
_, err := e.Exec(query, args...)
104
-
return err
105
-
}
106
-
107
-
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
108
-
err := DeleteRepoLanguages(
109
-
tx,
110
-
FilterEq("repo_at", repoAt),
111
-
FilterEq("ref", ref),
112
-
)
113
-
if err != nil {
114
-
return fmt.Errorf("failed to delete existing languages: %w", err)
115
-
}
116
-
117
-
return InsertRepoLanguages(tx, langs)
118
-
}
+54
-103
appview/db/notifications.go
+54
-103
appview/db/notifications.go
···
8
8
"strings"
9
9
"time"
10
10
11
-
"github.com/bluesky-social/indigo/atproto/syntax"
12
11
"tangled.org/core/appview/models"
13
12
"tangled.org/core/appview/pagination"
14
13
)
15
14
16
-
func CreateNotification(e Execer, notification *models.Notification) error {
15
+
func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error {
17
16
query := `
18
17
INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id)
19
18
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
20
19
`
21
20
22
-
result, err := e.Exec(query,
21
+
result, err := d.DB.ExecContext(ctx, query,
23
22
notification.RecipientDid,
24
23
notification.ActorDid,
25
24
string(notification.Type),
···
59
58
for _, condition := range conditions[1:] {
60
59
whereClause += " AND " + condition
61
60
}
62
-
}
63
-
pageClause := ""
64
-
if page.Limit > 0 {
65
-
pageClause = " limit ? offset ? "
66
-
args = append(args, page.Limit, page.Offset)
67
61
}
68
62
69
63
query := fmt.Sprintf(`
···
71
65
from notifications
72
66
%s
73
67
order by created desc
74
-
%s
75
-
`, whereClause, pageClause)
68
+
limit ? offset ?
69
+
`, whereClause)
70
+
71
+
args = append(args, page.Limit, page.Offset)
76
72
77
73
rows, err := e.QueryContext(context.Background(), query, args...)
78
74
if err != nil {
···
134
130
select
135
131
n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
136
132
n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
137
-
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, r.website as r_website, r.topics as r_topics,
133
+
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description,
138
134
i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open,
139
135
p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state
140
136
from notifications n
···
163
159
var issue models.Issue
164
160
var pull models.Pull
165
161
var rId, iId, pId sql.NullInt64
166
-
var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString
162
+
var rDid, rName, rDescription sql.NullString
167
163
var iDid sql.NullString
168
164
var iIssueId sql.NullInt64
169
165
var iTitle sql.NullString
···
176
172
err := rows.Scan(
177
173
&n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
178
174
&n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
179
-
&rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr,
175
+
&rId, &rDid, &rName, &rDescription,
180
176
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
181
177
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
182
178
)
···
203
199
}
204
200
if rDescription.Valid {
205
201
repo.Description = rDescription.String
206
-
}
207
-
if rWebsite.Valid {
208
-
repo.Website = rWebsite.String
209
-
}
210
-
if rTopicStr.Valid {
211
-
repo.Topics = strings.Fields(rTopicStr.String)
212
202
}
213
203
nwe.Repo = &repo
214
204
}
···
284
274
return count, nil
285
275
}
286
276
287
-
func MarkNotificationRead(e Execer, notificationID int64, userDID string) error {
277
+
func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error {
288
278
idFilter := FilterEq("id", notificationID)
289
279
recipientFilter := FilterEq("recipient_did", userDID)
290
280
···
296
286
297
287
args := append(idFilter.Arg(), recipientFilter.Arg()...)
298
288
299
-
result, err := e.Exec(query, args...)
289
+
result, err := d.DB.ExecContext(ctx, query, args...)
300
290
if err != nil {
301
291
return fmt.Errorf("failed to mark notification as read: %w", err)
302
292
}
···
313
303
return nil
314
304
}
315
305
316
-
func MarkAllNotificationsRead(e Execer, userDID string) error {
306
+
func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error {
317
307
recipientFilter := FilterEq("recipient_did", userDID)
318
308
readFilter := FilterEq("read", 0)
319
309
···
325
315
326
316
args := append(recipientFilter.Arg(), readFilter.Arg()...)
327
317
328
-
_, err := e.Exec(query, args...)
318
+
_, err := d.DB.ExecContext(ctx, query, args...)
329
319
if err != nil {
330
320
return fmt.Errorf("failed to mark all notifications as read: %w", err)
331
321
}
···
333
323
return nil
334
324
}
335
325
336
-
func DeleteNotification(e Execer, notificationID int64, userDID string) error {
326
+
func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error {
337
327
idFilter := FilterEq("id", notificationID)
338
328
recipientFilter := FilterEq("recipient_did", userDID)
339
329
···
344
334
345
335
args := append(idFilter.Arg(), recipientFilter.Arg()...)
346
336
347
-
result, err := e.Exec(query, args...)
337
+
result, err := d.DB.ExecContext(ctx, query, args...)
348
338
if err != nil {
349
339
return fmt.Errorf("failed to delete notification: %w", err)
350
340
}
···
361
351
return nil
362
352
}
363
353
364
-
func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) {
365
-
prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid))
366
-
if err != nil {
367
-
return nil, err
368
-
}
354
+
func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) {
355
+
userFilter := FilterEq("user_did", userDID)
369
356
370
-
p, ok := prefs[syntax.DID(userDid)]
371
-
if !ok {
372
-
return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil
373
-
}
357
+
query := fmt.Sprintf(`
358
+
SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created,
359
+
pull_commented, followed, pull_merged, issue_closed, email_notifications
360
+
FROM notification_preferences
361
+
WHERE %s
362
+
`, userFilter.Condition())
374
363
375
-
return p, nil
376
-
}
364
+
var prefs models.NotificationPreferences
365
+
err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan(
366
+
&prefs.ID,
367
+
&prefs.UserDid,
368
+
&prefs.RepoStarred,
369
+
&prefs.IssueCreated,
370
+
&prefs.IssueCommented,
371
+
&prefs.PullCreated,
372
+
&prefs.PullCommented,
373
+
&prefs.Followed,
374
+
&prefs.PullMerged,
375
+
&prefs.IssueClosed,
376
+
&prefs.EmailNotifications,
377
+
)
377
378
378
-
func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) {
379
-
prefsMap := make(map[syntax.DID]*models.NotificationPreferences)
380
-
381
-
var conditions []string
382
-
var args []any
383
-
for _, filter := range filters {
384
-
conditions = append(conditions, filter.Condition())
385
-
args = append(args, filter.Arg()...)
386
-
}
387
-
388
-
whereClause := ""
389
-
if conditions != nil {
390
-
whereClause = " where " + strings.Join(conditions, " and ")
391
-
}
392
-
393
-
query := fmt.Sprintf(`
394
-
select
395
-
id,
396
-
user_did,
397
-
repo_starred,
398
-
issue_created,
399
-
issue_commented,
400
-
pull_created,
401
-
pull_commented,
402
-
followed,
403
-
user_mentioned,
404
-
pull_merged,
405
-
issue_closed,
406
-
email_notifications
407
-
from
408
-
notification_preferences
409
-
%s
410
-
`, whereClause)
411
-
412
-
rows, err := e.Query(query, args...)
413
379
if err != nil {
414
-
return nil, err
415
-
}
416
-
defer rows.Close()
417
-
418
-
for rows.Next() {
419
-
var prefs models.NotificationPreferences
420
-
if err := rows.Scan(
421
-
&prefs.ID,
422
-
&prefs.UserDid,
423
-
&prefs.RepoStarred,
424
-
&prefs.IssueCreated,
425
-
&prefs.IssueCommented,
426
-
&prefs.PullCreated,
427
-
&prefs.PullCommented,
428
-
&prefs.Followed,
429
-
&prefs.UserMentioned,
430
-
&prefs.PullMerged,
431
-
&prefs.IssueClosed,
432
-
&prefs.EmailNotifications,
433
-
); err != nil {
434
-
return nil, err
380
+
if err == sql.ErrNoRows {
381
+
return &models.NotificationPreferences{
382
+
UserDid: userDID,
383
+
RepoStarred: true,
384
+
IssueCreated: true,
385
+
IssueCommented: true,
386
+
PullCreated: true,
387
+
PullCommented: true,
388
+
Followed: true,
389
+
PullMerged: true,
390
+
IssueClosed: true,
391
+
EmailNotifications: false,
392
+
}, nil
435
393
}
436
-
437
-
prefsMap[prefs.UserDid] = &prefs
394
+
return nil, fmt.Errorf("failed to get notification preferences: %w", err)
438
395
}
439
396
440
-
if err := rows.Err(); err != nil {
441
-
return nil, err
442
-
}
443
-
444
-
return prefsMap, nil
397
+
return &prefs, nil
445
398
}
446
399
447
400
func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
448
401
query := `
449
402
INSERT OR REPLACE INTO notification_preferences
450
403
(user_did, repo_starred, issue_created, issue_commented, pull_created,
451
-
pull_commented, followed, user_mentioned, pull_merged, issue_closed,
452
-
email_notifications)
453
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
404
+
pull_commented, followed, pull_merged, issue_closed, email_notifications)
405
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
454
406
`
455
407
456
408
result, err := d.DB.ExecContext(ctx, query,
···
461
413
prefs.PullCreated,
462
414
prefs.PullCommented,
463
415
prefs.Followed,
464
-
prefs.UserMentioned,
465
416
prefs.PullMerged,
466
417
prefs.IssueClosed,
467
418
prefs.EmailNotifications,
+6
-26
appview/db/profile.go
+6
-26
appview/db/profile.go
···
129
129
did,
130
130
description,
131
131
include_bluesky,
132
-
location,
133
-
pronouns
132
+
location
134
133
)
135
-
values (?, ?, ?, ?, ?)`,
134
+
values (?, ?, ?, ?)`,
136
135
profile.Did,
137
136
profile.Description,
138
137
includeBskyValue,
139
138
profile.Location,
140
-
profile.Pronouns,
141
139
)
142
140
143
141
if err != nil {
···
218
216
did,
219
217
description,
220
218
include_bluesky,
221
-
location,
222
-
pronouns
219
+
location
223
220
from
224
221
profile
225
222
%s`,
···
234
231
for rows.Next() {
235
232
var profile models.Profile
236
233
var includeBluesky int
237
-
var pronouns sql.Null[string]
238
234
239
-
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns)
235
+
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
240
236
if err != nil {
241
237
return nil, err
242
238
}
243
239
244
240
if includeBluesky != 0 {
245
241
profile.IncludeBluesky = true
246
-
}
247
-
248
-
if pronouns.Valid {
249
-
profile.Pronouns = pronouns.V
250
242
}
251
243
252
244
profileMap[profile.Did] = &profile
···
310
302
311
303
func GetProfile(e Execer, did string) (*models.Profile, error) {
312
304
var profile models.Profile
313
-
var pronouns sql.Null[string]
314
-
315
305
profile.Did = did
316
306
317
307
includeBluesky := 0
318
-
319
308
err := e.QueryRow(
320
-
`select description, include_bluesky, location, pronouns from profile where did = ?`,
309
+
`select description, include_bluesky, location from profile where did = ?`,
321
310
did,
322
-
).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns)
311
+
).Scan(&profile.Description, &includeBluesky, &profile.Location)
323
312
if err == sql.ErrNoRows {
324
313
profile := models.Profile{}
325
314
profile.Did = did
···
332
321
333
322
if includeBluesky != 0 {
334
323
profile.IncludeBluesky = true
335
-
}
336
-
337
-
if pronouns.Valid {
338
-
profile.Pronouns = pronouns.V
339
324
}
340
325
341
326
rows, err := e.Query(`select link from profile_links where did = ?`, did)
···
427
412
// ensure description is not too long
428
413
if len(profile.Location) > 40 {
429
414
return fmt.Errorf("Entered location is too long.")
430
-
}
431
-
432
-
// ensure pronouns are not too long
433
-
if len(profile.Pronouns) > 40 {
434
-
return fmt.Errorf("Entered pronouns are too long.")
435
415
}
436
416
437
417
// ensure links are in order
+221
-201
appview/db/pulls.go
+221
-201
appview/db/pulls.go
···
1
1
package db
2
2
3
3
import (
4
-
"cmp"
5
4
"database/sql"
6
-
"errors"
7
5
"fmt"
8
-
"maps"
9
-
"slices"
6
+
"log"
10
7
"sort"
11
8
"strings"
12
9
"time"
···
90
87
pull.ID = int(id)
91
88
92
89
_, err = tx.Exec(`
93
-
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
90
+
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
94
91
values (?, ?, ?, ?, ?)
95
-
`, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
92
+
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
96
93
return err
97
94
}
98
95
···
101
98
if err != nil {
102
99
return "", err
103
100
}
104
-
return pull.AtUri(), err
101
+
return pull.PullAt(), err
105
102
}
106
103
107
104
func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) {
···
111
108
}
112
109
113
110
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
114
-
pulls := make(map[syntax.ATURI]*models.Pull)
111
+
pulls := make(map[int]*models.Pull)
115
112
116
113
var conditions []string
117
114
var args []any
···
214
211
pull.ParentChangeId = parentChangeId.String
215
212
}
216
213
217
-
pulls[pull.AtUri()] = &pull
214
+
pulls[pull.PullId] = &pull
218
215
}
219
216
220
-
var pullAts []syntax.ATURI
217
+
// get latest round no. for each pull
218
+
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
219
+
submissionsQuery := fmt.Sprintf(`
220
+
select
221
+
id, pull_id, round_number, patch, created, source_rev
222
+
from
223
+
pull_submissions
224
+
where
225
+
repo_at in (%s) and pull_id in (%s)
226
+
`, inClause, inClause)
227
+
228
+
args = make([]any, len(pulls)*2)
229
+
idx := 0
230
+
for _, p := range pulls {
231
+
args[idx] = p.RepoAt
232
+
idx += 1
233
+
}
221
234
for _, p := range pulls {
222
-
pullAts = append(pullAts, p.AtUri())
235
+
args[idx] = p.PullId
236
+
idx += 1
223
237
}
224
-
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
238
+
submissionsRows, err := e.Query(submissionsQuery, args...)
225
239
if err != nil {
226
-
return nil, fmt.Errorf("failed to get submissions: %w", err)
240
+
return nil, err
227
241
}
242
+
defer submissionsRows.Close()
228
243
229
-
for pullAt, submissions := range submissionsMap {
230
-
if p, ok := pulls[pullAt]; ok {
231
-
p.Submissions = submissions
244
+
for submissionsRows.Next() {
245
+
var s models.PullSubmission
246
+
var sourceRev sql.NullString
247
+
var createdAt string
248
+
err := submissionsRows.Scan(
249
+
&s.ID,
250
+
&s.PullId,
251
+
&s.RoundNumber,
252
+
&s.Patch,
253
+
&createdAt,
254
+
&sourceRev,
255
+
)
256
+
if err != nil {
257
+
return nil, err
232
258
}
233
-
}
259
+
260
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
261
+
if err != nil {
262
+
return nil, err
263
+
}
264
+
s.Created = createdTime
265
+
266
+
if sourceRev.Valid {
267
+
s.SourceRev = sourceRev.String
268
+
}
234
269
235
-
// collect allLabels for each issue
236
-
allLabels, err := GetLabels(e, FilterIn("subject", pullAts))
237
-
if err != nil {
238
-
return nil, fmt.Errorf("failed to query labels: %w", err)
270
+
if p, ok := pulls[s.PullId]; ok {
271
+
p.Submissions = make([]*models.PullSubmission, s.RoundNumber+1)
272
+
p.Submissions[s.RoundNumber] = &s
273
+
}
239
274
}
240
-
for pullAt, labels := range allLabels {
241
-
if p, ok := pulls[pullAt]; ok {
242
-
p.Labels = labels
243
-
}
275
+
if err := rows.Err(); err != nil {
276
+
return nil, err
244
277
}
245
278
246
-
// collect pull source for all pulls that need it
247
-
var sourceAts []syntax.ATURI
279
+
// get comment count on latest submission on each pull
280
+
inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
281
+
commentsQuery := fmt.Sprintf(`
282
+
select
283
+
count(id), pull_id
284
+
from
285
+
pull_comments
286
+
where
287
+
submission_id in (%s)
288
+
group by
289
+
submission_id
290
+
`, inClause)
291
+
292
+
args = []any{}
248
293
for _, p := range pulls {
249
-
if p.PullSource != nil && p.PullSource.RepoAt != nil {
250
-
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
251
-
}
294
+
args = append(args, p.Submissions[p.LastRoundNumber()].ID)
252
295
}
253
-
sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts))
254
-
if err != nil && !errors.Is(err, sql.ErrNoRows) {
255
-
return nil, fmt.Errorf("failed to get source repos: %w", err)
296
+
commentsRows, err := e.Query(commentsQuery, args...)
297
+
if err != nil {
298
+
return nil, err
256
299
}
257
-
sourceRepoMap := make(map[syntax.ATURI]*models.Repo)
258
-
for _, r := range sourceRepos {
259
-
sourceRepoMap[r.RepoAt()] = &r
300
+
defer commentsRows.Close()
301
+
302
+
for commentsRows.Next() {
303
+
var commentCount, pullId int
304
+
err := commentsRows.Scan(
305
+
&commentCount,
306
+
&pullId,
307
+
)
308
+
if err != nil {
309
+
return nil, err
310
+
}
311
+
if p, ok := pulls[pullId]; ok {
312
+
p.Submissions[p.LastRoundNumber()].Comments = make([]models.PullComment, commentCount)
313
+
}
260
314
}
261
-
for _, p := range pulls {
262
-
if p.PullSource != nil && p.PullSource.RepoAt != nil {
263
-
if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok {
264
-
p.PullSource.Repo = sourceRepo
265
-
}
266
-
}
315
+
if err := rows.Err(); err != nil {
316
+
return nil, err
267
317
}
268
318
269
319
orderedByPullId := []*models.Pull{}
···
281
331
return GetPullsWithLimit(e, 0, filters...)
282
332
}
283
333
284
-
func GetPullIDs(e Execer, opts models.PullSearchOptions) ([]int64, error) {
285
-
var ids []int64
286
-
287
-
var filters []filter
288
-
filters = append(filters, FilterEq("state", opts.State))
289
-
if opts.RepoAt != "" {
290
-
filters = append(filters, FilterEq("repo_at", opts.RepoAt))
291
-
}
292
-
293
-
var conditions []string
294
-
var args []any
295
-
296
-
for _, filter := range filters {
297
-
conditions = append(conditions, filter.Condition())
298
-
args = append(args, filter.Arg()...)
299
-
}
300
-
301
-
whereClause := ""
302
-
if conditions != nil {
303
-
whereClause = " where " + strings.Join(conditions, " and ")
304
-
}
305
-
pageClause := ""
306
-
if opts.Page.Limit != 0 {
307
-
pageClause = fmt.Sprintf(
308
-
" limit %d offset %d ",
309
-
opts.Page.Limit,
310
-
opts.Page.Offset,
311
-
)
312
-
}
313
-
314
-
query := fmt.Sprintf(
315
-
`
334
+
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
335
+
query := `
316
336
select
317
-
id
337
+
id,
338
+
owner_did,
339
+
pull_id,
340
+
created,
341
+
title,
342
+
state,
343
+
target_branch,
344
+
repo_at,
345
+
body,
346
+
rkey,
347
+
source_branch,
348
+
source_repo_at,
349
+
stack_id,
350
+
change_id,
351
+
parent_change_id
318
352
from
319
353
pulls
320
-
%s
321
-
%s`,
322
-
whereClause,
323
-
pageClause,
354
+
where
355
+
repo_at = ? and pull_id = ?
356
+
`
357
+
row := e.QueryRow(query, repoAt, pullId)
358
+
359
+
var pull models.Pull
360
+
var createdAt string
361
+
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
362
+
err := row.Scan(
363
+
&pull.ID,
364
+
&pull.OwnerDid,
365
+
&pull.PullId,
366
+
&createdAt,
367
+
&pull.Title,
368
+
&pull.State,
369
+
&pull.TargetBranch,
370
+
&pull.RepoAt,
371
+
&pull.Body,
372
+
&pull.Rkey,
373
+
&sourceBranch,
374
+
&sourceRepoAt,
375
+
&stackId,
376
+
&changeId,
377
+
&parentChangeId,
324
378
)
325
-
args = append(args, opts.Page.Limit, opts.Page.Offset)
326
-
rows, err := e.Query(query, args...)
327
379
if err != nil {
328
380
return nil, err
329
381
}
330
-
defer rows.Close()
331
382
332
-
for rows.Next() {
333
-
var id int64
334
-
err := rows.Scan(&id)
335
-
if err != nil {
336
-
return nil, err
337
-
}
338
-
339
-
ids = append(ids, id)
340
-
}
341
-
342
-
return ids, nil
343
-
}
344
-
345
-
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
346
-
pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId))
383
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
347
384
if err != nil {
348
385
return nil, err
349
386
}
350
-
if len(pulls) == 0 {
351
-
return nil, sql.ErrNoRows
352
-
}
387
+
pull.Created = createdTime
353
388
354
-
return pulls[0], nil
355
-
}
356
-
357
-
// mapping from pull -> pull submissions
358
-
func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
359
-
var conditions []string
360
-
var args []any
361
-
for _, filter := range filters {
362
-
conditions = append(conditions, filter.Condition())
363
-
args = append(args, filter.Arg()...)
389
+
// populate source
390
+
if sourceBranch.Valid {
391
+
pull.PullSource = &models.PullSource{
392
+
Branch: sourceBranch.String,
393
+
}
394
+
if sourceRepoAt.Valid {
395
+
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
396
+
if err != nil {
397
+
return nil, err
398
+
}
399
+
pull.PullSource.RepoAt = &sourceRepoAtParsed
400
+
}
364
401
}
365
402
366
-
whereClause := ""
367
-
if conditions != nil {
368
-
whereClause = " where " + strings.Join(conditions, " and ")
403
+
if stackId.Valid {
404
+
pull.StackId = stackId.String
405
+
}
406
+
if changeId.Valid {
407
+
pull.ChangeId = changeId.String
408
+
}
409
+
if parentChangeId.Valid {
410
+
pull.ParentChangeId = parentChangeId.String
369
411
}
370
412
371
-
query := fmt.Sprintf(`
413
+
submissionsQuery := `
372
414
select
373
-
id,
374
-
pull_at,
375
-
round_number,
376
-
patch,
377
-
combined,
378
-
created,
379
-
source_rev
415
+
id, pull_id, repo_at, round_number, patch, created, source_rev
380
416
from
381
417
pull_submissions
382
-
%s
383
-
order by
384
-
round_number asc
385
-
`, whereClause)
386
-
387
-
rows, err := e.Query(query, args...)
418
+
where
419
+
repo_at = ? and pull_id = ?
420
+
`
421
+
submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId)
388
422
if err != nil {
389
423
return nil, err
390
424
}
391
-
defer rows.Close()
425
+
defer submissionsRows.Close()
392
426
393
-
submissionMap := make(map[int]*models.PullSubmission)
427
+
submissionsMap := make(map[int]*models.PullSubmission)
394
428
395
-
for rows.Next() {
429
+
for submissionsRows.Next() {
396
430
var submission models.PullSubmission
397
431
var submissionCreatedStr string
398
-
var submissionSourceRev, submissionCombined sql.NullString
399
-
err := rows.Scan(
432
+
var submissionSourceRev sql.NullString
433
+
err := submissionsRows.Scan(
400
434
&submission.ID,
401
-
&submission.PullAt,
435
+
&submission.PullId,
436
+
&submission.RepoAt,
402
437
&submission.RoundNumber,
403
438
&submission.Patch,
404
-
&submissionCombined,
405
439
&submissionCreatedStr,
406
440
&submissionSourceRev,
407
441
)
···
409
443
return nil, err
410
444
}
411
445
412
-
if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil {
413
-
submission.Created = t
446
+
submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr)
447
+
if err != nil {
448
+
return nil, err
414
449
}
450
+
submission.Created = submissionCreatedTime
415
451
416
452
if submissionSourceRev.Valid {
417
453
submission.SourceRev = submissionSourceRev.String
418
454
}
419
455
420
-
if submissionCombined.Valid {
421
-
submission.Combined = submissionCombined.String
422
-
}
423
-
424
-
submissionMap[submission.ID] = &submission
456
+
submissionsMap[submission.ID] = &submission
425
457
}
426
-
427
-
if err := rows.Err(); err != nil {
458
+
if err = submissionsRows.Close(); err != nil {
428
459
return nil, err
429
460
}
430
-
431
-
// Get comments for all submissions using GetPullComments
432
-
submissionIds := slices.Collect(maps.Keys(submissionMap))
433
-
comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds))
434
-
if err != nil {
435
-
return nil, err
436
-
}
437
-
for _, comment := range comments {
438
-
if submission, ok := submissionMap[comment.SubmissionId]; ok {
439
-
submission.Comments = append(submission.Comments, comment)
440
-
}
461
+
if len(submissionsMap) == 0 {
462
+
return &pull, nil
441
463
}
442
464
443
-
// group the submissions by pull_at
444
-
m := make(map[syntax.ATURI][]*models.PullSubmission)
445
-
for _, s := range submissionMap {
446
-
m[s.PullAt] = append(m[s.PullAt], s)
447
-
}
448
-
449
-
// sort each one by round number
450
-
for _, s := range m {
451
-
slices.SortFunc(s, func(a, b *models.PullSubmission) int {
452
-
return cmp.Compare(a.RoundNumber, b.RoundNumber)
453
-
})
454
-
}
455
-
456
-
return m, nil
457
-
}
458
-
459
-
func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) {
460
-
var conditions []string
461
465
var args []any
462
-
for _, filter := range filters {
463
-
conditions = append(conditions, filter.Condition())
464
-
args = append(args, filter.Arg()...)
465
-
}
466
-
467
-
whereClause := ""
468
-
if conditions != nil {
469
-
whereClause = " where " + strings.Join(conditions, " and ")
466
+
for k := range submissionsMap {
467
+
args = append(args, k)
470
468
}
471
-
472
-
query := fmt.Sprintf(`
469
+
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ")
470
+
commentsQuery := fmt.Sprintf(`
473
471
select
474
472
id,
475
473
pull_id,
···
481
479
created
482
480
from
483
481
pull_comments
484
-
%s
482
+
where
483
+
submission_id IN (%s)
485
484
order by
486
485
created asc
487
-
`, whereClause)
488
-
489
-
rows, err := e.Query(query, args...)
486
+
`, inClause)
487
+
commentsRows, err := e.Query(commentsQuery, args...)
490
488
if err != nil {
491
489
return nil, err
492
490
}
493
-
defer rows.Close()
491
+
defer commentsRows.Close()
494
492
495
-
var comments []models.PullComment
496
-
for rows.Next() {
493
+
for commentsRows.Next() {
497
494
var comment models.PullComment
498
-
var createdAt string
499
-
err := rows.Scan(
495
+
var commentCreatedStr string
496
+
err := commentsRows.Scan(
500
497
&comment.ID,
501
498
&comment.PullId,
502
499
&comment.SubmissionId,
···
504
501
&comment.OwnerDid,
505
502
&comment.CommentAt,
506
503
&comment.Body,
507
-
&createdAt,
504
+
&commentCreatedStr,
508
505
)
509
506
if err != nil {
510
507
return nil, err
511
508
}
512
509
513
-
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
514
-
comment.Created = t
510
+
commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr)
511
+
if err != nil {
512
+
return nil, err
513
+
}
514
+
comment.Created = commentCreatedTime
515
+
516
+
// Add the comment to its submission
517
+
if submission, ok := submissionsMap[comment.SubmissionId]; ok {
518
+
submission.Comments = append(submission.Comments, comment)
515
519
}
516
520
517
-
comments = append(comments, comment)
518
521
}
519
-
520
-
if err := rows.Err(); err != nil {
522
+
if err = commentsRows.Err(); err != nil {
521
523
return nil, err
522
524
}
523
525
524
-
return comments, nil
526
+
var pullSourceRepo *models.Repo
527
+
if pull.PullSource != nil {
528
+
if pull.PullSource.RepoAt != nil {
529
+
pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String())
530
+
if err != nil {
531
+
log.Printf("failed to get repo by at uri: %v", err)
532
+
} else {
533
+
pull.PullSource.Repo = pullSourceRepo
534
+
}
535
+
}
536
+
}
537
+
538
+
pull.Submissions = make([]*models.PullSubmission, len(submissionsMap))
539
+
for _, submission := range submissionsMap {
540
+
pull.Submissions[submission.RoundNumber] = submission
541
+
}
542
+
543
+
return &pull, nil
525
544
}
526
545
527
546
// timeframe here is directly passed into the sql query filter, and any
···
655
674
return err
656
675
}
657
676
658
-
func ResubmitPull(e Execer, pullAt syntax.ATURI, newRoundNumber int, newPatch string, combinedPatch string, newSourceRev string) error {
677
+
func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error {
678
+
newRoundNumber := len(pull.Submissions)
659
679
_, err := e.Exec(`
660
-
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
680
+
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
661
681
values (?, ?, ?, ?, ?)
662
-
`, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
682
+
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
663
683
664
684
return err
665
685
}
+7
-34
appview/db/reaction.go
+7
-34
appview/db/reaction.go
···
62
62
return count, nil
63
63
}
64
64
65
-
func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) {
66
-
query := `
67
-
select kind, reacted_by_did,
68
-
row_number() over (partition by kind order by created asc) as rn,
69
-
count(*) over (partition by kind) as total
70
-
from reactions
71
-
where thread_at = ?
72
-
order by kind, created asc`
73
-
74
-
rows, err := e.Query(query, threadAt)
75
-
if err != nil {
76
-
return nil, err
77
-
}
78
-
defer rows.Close()
79
-
80
-
reactionMap := map[models.ReactionKind]models.ReactionDisplayData{}
65
+
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) {
66
+
countMap := map[models.ReactionKind]int{}
81
67
for _, kind := range models.OrderedReactionKinds {
82
-
reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}}
83
-
}
84
-
85
-
for rows.Next() {
86
-
var kind models.ReactionKind
87
-
var did string
88
-
var rn, total int
89
-
if err := rows.Scan(&kind, &did, &rn, &total); err != nil {
90
-
return nil, err
68
+
count, err := GetReactionCount(e, threadAt, kind)
69
+
if err != nil {
70
+
return map[models.ReactionKind]int{}, nil
91
71
}
92
-
93
-
data := reactionMap[kind]
94
-
data.Count = total
95
-
if userLimit > 0 && rn <= userLimit {
96
-
data.Users = append(data.Users, did)
97
-
}
98
-
reactionMap[kind] = data
72
+
countMap[kind] = count
99
73
}
100
-
101
-
return reactionMap, rows.Err()
74
+
return countMap, nil
102
75
}
103
76
104
77
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+12
-50
appview/db/repos.go
+12
-50
appview/db/repos.go
···
70
70
rkey,
71
71
created,
72
72
description,
73
-
website,
74
-
topics,
75
73
source,
76
74
spindle
77
75
from
···
91
89
for rows.Next() {
92
90
var repo models.Repo
93
91
var createdAt string
94
-
var description, website, topicStr, source, spindle sql.NullString
92
+
var description, source, spindle sql.NullString
95
93
96
94
err := rows.Scan(
97
95
&repo.Id,
···
101
99
&repo.Rkey,
102
100
&createdAt,
103
101
&description,
104
-
&website,
105
-
&topicStr,
106
102
&source,
107
103
&spindle,
108
104
)
···
115
111
}
116
112
if description.Valid {
117
113
repo.Description = description.String
118
-
}
119
-
if website.Valid {
120
-
repo.Website = website.String
121
-
}
122
-
if topicStr.Valid {
123
-
repo.Topics = strings.Fields(topicStr.String)
124
114
}
125
115
if source.Valid {
126
116
repo.Source = source.String
···
366
356
func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) {
367
357
var repo models.Repo
368
358
var nullableDescription sql.NullString
369
-
var nullableWebsite sql.NullString
370
-
var nullableTopicStr sql.NullString
371
359
372
-
row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri)
360
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
373
361
374
362
var createdAt string
375
-
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil {
363
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
376
364
return nil, err
377
365
}
378
366
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
380
368
381
369
if nullableDescription.Valid {
382
370
repo.Description = nullableDescription.String
383
-
}
384
-
if nullableWebsite.Valid {
385
-
repo.Website = nullableWebsite.String
386
-
}
387
-
if nullableTopicStr.Valid {
388
-
repo.Topics = strings.Fields(nullableTopicStr.String)
371
+
} else {
372
+
repo.Description = ""
389
373
}
390
374
391
375
return &repo, nil
392
-
}
393
-
394
-
func PutRepo(tx *sql.Tx, repo models.Repo) error {
395
-
_, err := tx.Exec(
396
-
`update repos
397
-
set knot = ?, description = ?, website = ?, topics = ?
398
-
where did = ? and rkey = ?
399
-
`,
400
-
repo.Knot, repo.Description, repo.Website, repo.TopicStr(), repo.Did, repo.Rkey,
401
-
)
402
-
return err
403
376
}
404
377
405
378
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
406
379
_, err := tx.Exec(
407
380
`insert into repos
408
-
(did, name, knot, rkey, at_uri, description, website, topics, source)
409
-
values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
410
-
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source,
381
+
(did, name, knot, rkey, at_uri, description, source)
382
+
values (?, ?, ?, ?, ?, ?, ?)`,
383
+
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
411
384
)
412
385
if err != nil {
413
386
return fmt.Errorf("failed to insert repo: %w", err)
···
443
416
var repos []models.Repo
444
417
445
418
rows, err := e.Query(
446
-
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source
419
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
447
420
from repos r
448
421
left join collaborators c on r.at_uri = c.repo_at
449
422
where (r.did = ? or c.subject_did = ?)
···
461
434
var repo models.Repo
462
435
var createdAt string
463
436
var nullableDescription sql.NullString
464
-
var nullableWebsite sql.NullString
465
437
var nullableSource sql.NullString
466
438
467
-
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource)
439
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
468
440
if err != nil {
469
441
return nil, err
470
442
}
···
498
470
var repo models.Repo
499
471
var createdAt string
500
472
var nullableDescription sql.NullString
501
-
var nullableWebsite sql.NullString
502
-
var nullableTopicStr sql.NullString
503
473
var nullableSource sql.NullString
504
474
505
475
row := e.QueryRow(
506
-
`select id, did, name, knot, rkey, description, website, topics, created, source
476
+
`select id, did, name, knot, rkey, description, created, source
507
477
from repos
508
478
where did = ? and name = ? and source is not null and source != ''`,
509
479
did, name,
510
480
)
511
481
512
-
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource)
482
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
513
483
if err != nil {
514
484
return nil, err
515
485
}
516
486
517
487
if nullableDescription.Valid {
518
488
repo.Description = nullableDescription.String
519
-
}
520
-
521
-
if nullableWebsite.Valid {
522
-
repo.Website = nullableWebsite.String
523
-
}
524
-
525
-
if nullableTopicStr.Valid {
526
-
repo.Topics = strings.Fields(nullableTopicStr.String)
527
489
}
528
490
529
491
if nullableSource.Valid {
+10
-38
appview/db/timeline.go
+10
-38
appview/db/timeline.go
···
9
9
10
10
// TODO: this gathers heterogenous events from different sources and aggregates
11
11
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
12
-
func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) {
12
+
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
13
13
var events []models.TimelineEvent
14
14
15
-
var userIsFollowing []string
16
-
if limitToUsersIsFollowing {
17
-
following, err := GetFollowing(e, loggedInUserDid)
18
-
if err != nil {
19
-
return nil, err
20
-
}
21
-
22
-
userIsFollowing = make([]string, 0, len(following))
23
-
for _, follow := range following {
24
-
userIsFollowing = append(userIsFollowing, follow.SubjectDid)
25
-
}
26
-
}
27
-
28
-
repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing)
15
+
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
29
16
if err != nil {
30
17
return nil, err
31
18
}
32
19
33
-
stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing)
20
+
stars, err := getTimelineStars(e, limit, loggedInUserDid)
34
21
if err != nil {
35
22
return nil, err
36
23
}
37
24
38
-
follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing)
25
+
follows, err := getTimelineFollows(e, limit, loggedInUserDid)
39
26
if err != nil {
40
27
return nil, err
41
28
}
···
83
70
return isStarred, starCount
84
71
}
85
72
86
-
func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
87
-
filters := make([]filter, 0)
88
-
if userIsFollowing != nil {
89
-
filters = append(filters, FilterIn("did", userIsFollowing))
90
-
}
91
-
92
-
repos, err := GetRepos(e, limit, filters...)
73
+
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
74
+
repos, err := GetRepos(e, limit)
93
75
if err != nil {
94
76
return nil, err
95
77
}
···
143
125
return events, nil
144
126
}
145
127
146
-
func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
147
-
filters := make([]filter, 0)
148
-
if userIsFollowing != nil {
149
-
filters = append(filters, FilterIn("starred_by_did", userIsFollowing))
150
-
}
151
-
152
-
stars, err := GetStars(e, limit, filters...)
128
+
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
129
+
stars, err := GetStars(e, limit)
153
130
if err != nil {
154
131
return nil, err
155
132
}
···
189
166
return events, nil
190
167
}
191
168
192
-
func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
193
-
filters := make([]filter, 0)
194
-
if userIsFollowing != nil {
195
-
filters = append(filters, FilterIn("user_did", userIsFollowing))
196
-
}
197
-
198
-
follows, err := GetFollows(e, limit, filters...)
169
+
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
170
+
follows, err := GetFollows(e, limit)
199
171
if err != nil {
200
172
return nil, err
201
173
}
+4
-4
appview/dns/cloudflare.go
+4
-4
appview/dns/cloudflare.go
···
30
30
return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil
31
31
}
32
32
33
-
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) (string, error) {
34
-
result, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
33
+
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error {
34
+
_, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
35
35
Type: record.Type,
36
36
Name: record.Name,
37
37
Content: record.Content,
···
39
39
Proxied: &record.Proxied,
40
40
})
41
41
if err != nil {
42
-
return "", fmt.Errorf("failed to create DNS record: %w", err)
42
+
return fmt.Errorf("failed to create DNS record: %w", err)
43
43
}
44
-
return result.ID, nil
44
+
return nil
45
45
}
46
46
47
47
func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
-20
appview/indexer/base36/base36.go
-20
appview/indexer/base36/base36.go
···
1
-
// mostly copied from gitea/modules/indexer/internal/base32
2
-
3
-
package base36
4
-
5
-
import (
6
-
"fmt"
7
-
"strconv"
8
-
)
9
-
10
-
func Encode(i int64) string {
11
-
return strconv.FormatInt(i, 36)
12
-
}
13
-
14
-
func Decode(s string) (int64, error) {
15
-
i, err := strconv.ParseInt(s, 36, 64)
16
-
if err != nil {
17
-
return 0, fmt.Errorf("invalid base36 integer %q: %w", s, err)
18
-
}
19
-
return i, nil
20
-
}
-58
appview/indexer/bleve/batch.go
-58
appview/indexer/bleve/batch.go
···
1
-
// Copyright 2021 The Gitea Authors. All rights reserved.
2
-
// SPDX-License-Identifier: MIT
3
-
4
-
package bleveutil
5
-
6
-
import (
7
-
"github.com/blevesearch/bleve/v2"
8
-
)
9
-
10
-
// FlushingBatch is a batch of operations that automatically flushes to the
11
-
// underlying index once it reaches a certain size.
12
-
type FlushingBatch struct {
13
-
maxBatchSize int
14
-
batch *bleve.Batch
15
-
index bleve.Index
16
-
}
17
-
18
-
// NewFlushingBatch creates a new flushing batch for the specified index. Once
19
-
// the number of operations in the batch reaches the specified limit, the batch
20
-
// automatically flushes its operations to the index.
21
-
func NewFlushingBatch(index bleve.Index, maxBatchSize int) *FlushingBatch {
22
-
return &FlushingBatch{
23
-
maxBatchSize: maxBatchSize,
24
-
batch: index.NewBatch(),
25
-
index: index,
26
-
}
27
-
}
28
-
29
-
// Index add a new index to batch
30
-
func (b *FlushingBatch) Index(id string, data any) error {
31
-
if err := b.batch.Index(id, data); err != nil {
32
-
return err
33
-
}
34
-
return b.flushIfFull()
35
-
}
36
-
37
-
// Delete add a delete index to batch
38
-
func (b *FlushingBatch) Delete(id string) error {
39
-
b.batch.Delete(id)
40
-
return b.flushIfFull()
41
-
}
42
-
43
-
func (b *FlushingBatch) flushIfFull() error {
44
-
if b.batch.Size() < b.maxBatchSize {
45
-
return nil
46
-
}
47
-
return b.Flush()
48
-
}
49
-
50
-
// Flush submit the batch and create a new one
51
-
func (b *FlushingBatch) Flush() error {
52
-
err := b.index.Batch(b.batch)
53
-
if err != nil {
54
-
return err
55
-
}
56
-
b.batch = b.index.NewBatch()
57
-
return nil
58
-
}
-26
appview/indexer/bleve/query.go
-26
appview/indexer/bleve/query.go
···
1
-
package bleveutil
2
-
3
-
import (
4
-
"github.com/blevesearch/bleve/v2"
5
-
"github.com/blevesearch/bleve/v2/search/query"
6
-
)
7
-
8
-
func MatchAndQuery(field, keyword, analyzer string, fuzziness int) query.Query {
9
-
q := bleve.NewMatchQuery(keyword)
10
-
q.FieldVal = field
11
-
q.Analyzer = analyzer
12
-
q.Fuzziness = fuzziness
13
-
return q
14
-
}
15
-
16
-
func BoolFieldQuery(field string, val bool) query.Query {
17
-
q := bleve.NewBoolFieldQuery(val)
18
-
q.FieldVal = field
19
-
return q
20
-
}
21
-
22
-
func KeywordFieldQuery(field, keyword string) query.Query {
23
-
q := bleve.NewTermQuery(keyword)
24
-
q.FieldVal = field
25
-
return q
26
-
}
-36
appview/indexer/indexer.go
-36
appview/indexer/indexer.go
···
1
-
package indexer
2
-
3
-
import (
4
-
"context"
5
-
"log/slog"
6
-
7
-
"tangled.org/core/appview/db"
8
-
issues_indexer "tangled.org/core/appview/indexer/issues"
9
-
pulls_indexer "tangled.org/core/appview/indexer/pulls"
10
-
"tangled.org/core/appview/notify"
11
-
tlog "tangled.org/core/log"
12
-
)
13
-
14
-
type Indexer struct {
15
-
Issues *issues_indexer.Indexer
16
-
Pulls *pulls_indexer.Indexer
17
-
logger *slog.Logger
18
-
notify.BaseNotifier
19
-
}
20
-
21
-
func New(logger *slog.Logger) *Indexer {
22
-
return &Indexer{
23
-
issues_indexer.NewIndexer("indexes/issues.bleve"),
24
-
pulls_indexer.NewIndexer("indexes/pulls.bleve"),
25
-
logger,
26
-
notify.BaseNotifier{},
27
-
}
28
-
}
29
-
30
-
// Init initializes all indexers
31
-
func (ix *Indexer) Init(ctx context.Context, db *db.DB) error {
32
-
ctx = tlog.IntoContext(ctx, ix.logger)
33
-
ix.Issues.Init(ctx, db)
34
-
ix.Pulls.Init(ctx, db)
35
-
return nil
36
-
}
-255
appview/indexer/issues/indexer.go
-255
appview/indexer/issues/indexer.go
···
1
-
// heavily inspired by gitea's model (basically copy-pasted)
2
-
package issues_indexer
3
-
4
-
import (
5
-
"context"
6
-
"errors"
7
-
"log"
8
-
"os"
9
-
10
-
"github.com/blevesearch/bleve/v2"
11
-
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
12
-
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
13
-
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
14
-
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
15
-
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
16
-
"github.com/blevesearch/bleve/v2/index/upsidedown"
17
-
"github.com/blevesearch/bleve/v2/mapping"
18
-
"github.com/blevesearch/bleve/v2/search/query"
19
-
"tangled.org/core/appview/db"
20
-
"tangled.org/core/appview/indexer/base36"
21
-
"tangled.org/core/appview/indexer/bleve"
22
-
"tangled.org/core/appview/models"
23
-
"tangled.org/core/appview/pagination"
24
-
tlog "tangled.org/core/log"
25
-
)
26
-
27
-
const (
28
-
issueIndexerAnalyzer = "issueIndexer"
29
-
issueIndexerDocType = "issueIndexerDocType"
30
-
31
-
unicodeNormalizeName = "uicodeNormalize"
32
-
)
33
-
34
-
type Indexer struct {
35
-
indexer bleve.Index
36
-
path string
37
-
}
38
-
39
-
func NewIndexer(indexDir string) *Indexer {
40
-
return &Indexer{
41
-
path: indexDir,
42
-
}
43
-
}
44
-
45
-
// Init initializes the indexer
46
-
func (ix *Indexer) Init(ctx context.Context, e db.Execer) {
47
-
l := tlog.FromContext(ctx)
48
-
existed, err := ix.intialize(ctx)
49
-
if err != nil {
50
-
log.Fatalln("failed to initialize issue indexer", err)
51
-
}
52
-
if !existed {
53
-
l.Debug("Populating the issue indexer")
54
-
err := PopulateIndexer(ctx, ix, e)
55
-
if err != nil {
56
-
log.Fatalln("failed to populate issue indexer", err)
57
-
}
58
-
}
59
-
l.Info("Initialized the issue indexer")
60
-
}
61
-
62
-
func generateIssueIndexMapping() (mapping.IndexMapping, error) {
63
-
mapping := bleve.NewIndexMapping()
64
-
docMapping := bleve.NewDocumentMapping()
65
-
66
-
textFieldMapping := bleve.NewTextFieldMapping()
67
-
textFieldMapping.Store = false
68
-
textFieldMapping.IncludeInAll = false
69
-
70
-
boolFieldMapping := bleve.NewBooleanFieldMapping()
71
-
boolFieldMapping.Store = false
72
-
boolFieldMapping.IncludeInAll = false
73
-
74
-
keywordFieldMapping := bleve.NewKeywordFieldMapping()
75
-
keywordFieldMapping.Store = false
76
-
keywordFieldMapping.IncludeInAll = false
77
-
78
-
// numericFieldMapping := bleve.NewNumericFieldMapping()
79
-
80
-
docMapping.AddFieldMappingsAt("title", textFieldMapping)
81
-
docMapping.AddFieldMappingsAt("body", textFieldMapping)
82
-
83
-
docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping)
84
-
docMapping.AddFieldMappingsAt("is_open", boolFieldMapping)
85
-
86
-
err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{
87
-
"type": unicodenorm.Name,
88
-
"form": unicodenorm.NFC,
89
-
})
90
-
if err != nil {
91
-
return nil, err
92
-
}
93
-
94
-
err = mapping.AddCustomAnalyzer(issueIndexerAnalyzer, map[string]any{
95
-
"type": custom.Name,
96
-
"char_filters": []string{},
97
-
"tokenizer": unicode.Name,
98
-
"token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name},
99
-
})
100
-
if err != nil {
101
-
return nil, err
102
-
}
103
-
104
-
mapping.DefaultAnalyzer = issueIndexerAnalyzer
105
-
mapping.AddDocumentMapping(issueIndexerDocType, docMapping)
106
-
mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping())
107
-
mapping.DefaultMapping = bleve.NewDocumentDisabledMapping()
108
-
109
-
return mapping, nil
110
-
}
111
-
112
-
func (ix *Indexer) intialize(ctx context.Context) (bool, error) {
113
-
if ix.indexer != nil {
114
-
return false, errors.New("indexer is already initialized")
115
-
}
116
-
117
-
indexer, err := openIndexer(ctx, ix.path)
118
-
if err != nil {
119
-
return false, err
120
-
}
121
-
if indexer != nil {
122
-
ix.indexer = indexer
123
-
return true, nil
124
-
}
125
-
126
-
mapping, err := generateIssueIndexMapping()
127
-
if err != nil {
128
-
return false, err
129
-
}
130
-
indexer, err = bleve.New(ix.path, mapping)
131
-
if err != nil {
132
-
return false, err
133
-
}
134
-
135
-
ix.indexer = indexer
136
-
137
-
return false, nil
138
-
}
139
-
140
-
func openIndexer(ctx context.Context, path string) (bleve.Index, error) {
141
-
l := tlog.FromContext(ctx)
142
-
indexer, err := bleve.Open(path)
143
-
if err != nil {
144
-
if errors.Is(err, upsidedown.IncompatibleVersion) {
145
-
l.Info("Indexer was built with a previous version of bleve, deleting and rebuilding")
146
-
return nil, os.RemoveAll(path)
147
-
}
148
-
return nil, nil
149
-
}
150
-
return indexer, nil
151
-
}
152
-
153
-
func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error {
154
-
l := tlog.FromContext(ctx)
155
-
count := 0
156
-
err := pagination.IterateAll(
157
-
func(page pagination.Page) ([]models.Issue, error) {
158
-
return db.GetIssuesPaginated(e, page)
159
-
},
160
-
func(issues []models.Issue) error {
161
-
count += len(issues)
162
-
return ix.Index(ctx, issues...)
163
-
},
164
-
)
165
-
l.Info("issues indexed", "count", count)
166
-
return err
167
-
}
168
-
169
-
// issueData data stored and will be indexed
170
-
type issueData struct {
171
-
ID int64 `json:"id"`
172
-
RepoAt string `json:"repo_at"`
173
-
IssueID int `json:"issue_id"`
174
-
Title string `json:"title"`
175
-
Body string `json:"body"`
176
-
177
-
IsOpen bool `json:"is_open"`
178
-
Comments []IssueCommentData `json:"comments"`
179
-
}
180
-
181
-
func makeIssueData(issue *models.Issue) *issueData {
182
-
return &issueData{
183
-
ID: issue.Id,
184
-
RepoAt: issue.RepoAt.String(),
185
-
IssueID: issue.IssueId,
186
-
Title: issue.Title,
187
-
Body: issue.Body,
188
-
IsOpen: issue.Open,
189
-
}
190
-
}
191
-
192
-
// Type returns the document type, for bleve's mapping.Classifier interface.
193
-
func (i *issueData) Type() string {
194
-
return issueIndexerDocType
195
-
}
196
-
197
-
type IssueCommentData struct {
198
-
Body string `json:"body"`
199
-
}
200
-
201
-
type SearchResult struct {
202
-
Hits []int64
203
-
Total uint64
204
-
}
205
-
206
-
const maxBatchSize = 20
207
-
208
-
func (ix *Indexer) Index(ctx context.Context, issues ...models.Issue) error {
209
-
batch := bleveutil.NewFlushingBatch(ix.indexer, maxBatchSize)
210
-
for _, issue := range issues {
211
-
issueData := makeIssueData(&issue)
212
-
if err := batch.Index(base36.Encode(issue.Id), issueData); err != nil {
213
-
return err
214
-
}
215
-
}
216
-
return batch.Flush()
217
-
}
218
-
219
-
func (ix *Indexer) Delete(ctx context.Context, issueId int64) error {
220
-
return ix.indexer.Delete(base36.Encode(issueId))
221
-
}
222
-
223
-
// Search searches for issues
224
-
func (ix *Indexer) Search(ctx context.Context, opts models.IssueSearchOptions) (*SearchResult, error) {
225
-
var queries []query.Query
226
-
227
-
if opts.Keyword != "" {
228
-
queries = append(queries, bleve.NewDisjunctionQuery(
229
-
bleveutil.MatchAndQuery("title", opts.Keyword, issueIndexerAnalyzer, 0),
230
-
bleveutil.MatchAndQuery("body", opts.Keyword, issueIndexerAnalyzer, 0),
231
-
))
232
-
}
233
-
queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt))
234
-
queries = append(queries, bleveutil.BoolFieldQuery("is_open", opts.IsOpen))
235
-
// TODO: append more queries
236
-
237
-
var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...)
238
-
searchReq := bleve.NewSearchRequestOptions(indexerQuery, opts.Page.Limit, opts.Page.Offset, false)
239
-
res, err := ix.indexer.SearchInContext(ctx, searchReq)
240
-
if err != nil {
241
-
return nil, nil
242
-
}
243
-
ret := &SearchResult{
244
-
Total: res.Total,
245
-
Hits: make([]int64, len(res.Hits)),
246
-
}
247
-
for i, hit := range res.Hits {
248
-
id, err := base36.Decode(hit.ID)
249
-
if err != nil {
250
-
return nil, err
251
-
}
252
-
ret.Hits[i] = id
253
-
}
254
-
return ret, nil
255
-
}
-57
appview/indexer/notifier.go
-57
appview/indexer/notifier.go
···
1
-
package indexer
2
-
3
-
import (
4
-
"context"
5
-
6
-
"github.com/bluesky-social/indigo/atproto/syntax"
7
-
"tangled.org/core/appview/models"
8
-
"tangled.org/core/appview/notify"
9
-
"tangled.org/core/log"
10
-
)
11
-
12
-
var _ notify.Notifier = &Indexer{}
13
-
14
-
func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
15
-
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
16
-
l.Debug("indexing new issue")
17
-
err := ix.Issues.Index(ctx, *issue)
18
-
if err != nil {
19
-
l.Error("failed to index an issue", "err", err)
20
-
}
21
-
}
22
-
23
-
func (ix *Indexer) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
24
-
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
25
-
l.Debug("updating an issue")
26
-
err := ix.Issues.Index(ctx, *issue)
27
-
if err != nil {
28
-
l.Error("failed to index an issue", "err", err)
29
-
}
30
-
}
31
-
32
-
func (ix *Indexer) DeleteIssue(ctx context.Context, issue *models.Issue) {
33
-
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
34
-
l.Debug("deleting an issue")
35
-
err := ix.Issues.Delete(ctx, issue.Id)
36
-
if err != nil {
37
-
l.Error("failed to delete an issue", "err", err)
38
-
}
39
-
}
40
-
41
-
func (ix *Indexer) NewPull(ctx context.Context, pull *models.Pull) {
42
-
l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull)
43
-
l.Debug("indexing new pr")
44
-
err := ix.Pulls.Index(ctx, pull)
45
-
if err != nil {
46
-
l.Error("failed to index a pr", "err", err)
47
-
}
48
-
}
49
-
50
-
func (ix *Indexer) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
51
-
l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull)
52
-
l.Debug("updating a pr")
53
-
err := ix.Pulls.Index(ctx, pull)
54
-
if err != nil {
55
-
l.Error("failed to index a pr", "err", err)
56
-
}
57
-
}
-255
appview/indexer/pulls/indexer.go
-255
appview/indexer/pulls/indexer.go
···
1
-
// heavily inspired by gitea's model (basically copy-pasted)
2
-
package pulls_indexer
3
-
4
-
import (
5
-
"context"
6
-
"errors"
7
-
"log"
8
-
"os"
9
-
10
-
"github.com/blevesearch/bleve/v2"
11
-
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
12
-
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
13
-
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
14
-
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
15
-
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
16
-
"github.com/blevesearch/bleve/v2/index/upsidedown"
17
-
"github.com/blevesearch/bleve/v2/mapping"
18
-
"github.com/blevesearch/bleve/v2/search/query"
19
-
"tangled.org/core/appview/db"
20
-
"tangled.org/core/appview/indexer/base36"
21
-
"tangled.org/core/appview/indexer/bleve"
22
-
"tangled.org/core/appview/models"
23
-
tlog "tangled.org/core/log"
24
-
)
25
-
26
-
const (
27
-
pullIndexerAnalyzer = "pullIndexer"
28
-
pullIndexerDocType = "pullIndexerDocType"
29
-
30
-
unicodeNormalizeName = "uicodeNormalize"
31
-
)
32
-
33
-
type Indexer struct {
34
-
indexer bleve.Index
35
-
path string
36
-
}
37
-
38
-
func NewIndexer(indexDir string) *Indexer {
39
-
return &Indexer{
40
-
path: indexDir,
41
-
}
42
-
}
43
-
44
-
// Init initializes the indexer
45
-
func (ix *Indexer) Init(ctx context.Context, e db.Execer) {
46
-
l := tlog.FromContext(ctx)
47
-
existed, err := ix.intialize(ctx)
48
-
if err != nil {
49
-
log.Fatalln("failed to initialize pull indexer", err)
50
-
}
51
-
if !existed {
52
-
l.Debug("Populating the pull indexer")
53
-
err := PopulateIndexer(ctx, ix, e)
54
-
if err != nil {
55
-
log.Fatalln("failed to populate pull indexer", err)
56
-
}
57
-
}
58
-
l.Info("Initialized the pull indexer")
59
-
}
60
-
61
-
func generatePullIndexMapping() (mapping.IndexMapping, error) {
62
-
mapping := bleve.NewIndexMapping()
63
-
docMapping := bleve.NewDocumentMapping()
64
-
65
-
textFieldMapping := bleve.NewTextFieldMapping()
66
-
textFieldMapping.Store = false
67
-
textFieldMapping.IncludeInAll = false
68
-
69
-
keywordFieldMapping := bleve.NewKeywordFieldMapping()
70
-
keywordFieldMapping.Store = false
71
-
keywordFieldMapping.IncludeInAll = false
72
-
73
-
// numericFieldMapping := bleve.NewNumericFieldMapping()
74
-
75
-
docMapping.AddFieldMappingsAt("title", textFieldMapping)
76
-
docMapping.AddFieldMappingsAt("body", textFieldMapping)
77
-
78
-
docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping)
79
-
docMapping.AddFieldMappingsAt("state", keywordFieldMapping)
80
-
81
-
err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{
82
-
"type": unicodenorm.Name,
83
-
"form": unicodenorm.NFC,
84
-
})
85
-
if err != nil {
86
-
return nil, err
87
-
}
88
-
89
-
err = mapping.AddCustomAnalyzer(pullIndexerAnalyzer, map[string]any{
90
-
"type": custom.Name,
91
-
"char_filters": []string{},
92
-
"tokenizer": unicode.Name,
93
-
"token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name},
94
-
})
95
-
if err != nil {
96
-
return nil, err
97
-
}
98
-
99
-
mapping.DefaultAnalyzer = pullIndexerAnalyzer
100
-
mapping.AddDocumentMapping(pullIndexerDocType, docMapping)
101
-
mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping())
102
-
mapping.DefaultMapping = bleve.NewDocumentDisabledMapping()
103
-
104
-
return mapping, nil
105
-
}
106
-
107
-
func (ix *Indexer) intialize(ctx context.Context) (bool, error) {
108
-
if ix.indexer != nil {
109
-
return false, errors.New("indexer is already initialized")
110
-
}
111
-
112
-
indexer, err := openIndexer(ctx, ix.path)
113
-
if err != nil {
114
-
return false, err
115
-
}
116
-
if indexer != nil {
117
-
ix.indexer = indexer
118
-
return true, nil
119
-
}
120
-
121
-
mapping, err := generatePullIndexMapping()
122
-
if err != nil {
123
-
return false, err
124
-
}
125
-
indexer, err = bleve.New(ix.path, mapping)
126
-
if err != nil {
127
-
return false, err
128
-
}
129
-
130
-
ix.indexer = indexer
131
-
132
-
return false, nil
133
-
}
134
-
135
-
func openIndexer(ctx context.Context, path string) (bleve.Index, error) {
136
-
l := tlog.FromContext(ctx)
137
-
indexer, err := bleve.Open(path)
138
-
if err != nil {
139
-
if errors.Is(err, upsidedown.IncompatibleVersion) {
140
-
l.Info("Indexer was built with a previous version of bleve, deleting and rebuilding")
141
-
return nil, os.RemoveAll(path)
142
-
}
143
-
return nil, nil
144
-
}
145
-
return indexer, nil
146
-
}
147
-
148
-
func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error {
149
-
l := tlog.FromContext(ctx)
150
-
151
-
pulls, err := db.GetPulls(e)
152
-
if err != nil {
153
-
return err
154
-
}
155
-
count := len(pulls)
156
-
err = ix.Index(ctx, pulls...)
157
-
if err != nil {
158
-
return err
159
-
}
160
-
l.Info("pulls indexed", "count", count)
161
-
return err
162
-
}
163
-
164
-
// pullData data stored and will be indexed
165
-
type pullData struct {
166
-
ID int64 `json:"id"`
167
-
RepoAt string `json:"repo_at"`
168
-
PullID int `json:"pull_id"`
169
-
Title string `json:"title"`
170
-
Body string `json:"body"`
171
-
State string `json:"state"`
172
-
173
-
Comments []pullCommentData `json:"comments"`
174
-
}
175
-
176
-
func makePullData(pull *models.Pull) *pullData {
177
-
return &pullData{
178
-
ID: int64(pull.ID),
179
-
RepoAt: pull.RepoAt.String(),
180
-
PullID: pull.PullId,
181
-
Title: pull.Title,
182
-
Body: pull.Body,
183
-
State: pull.State.String(),
184
-
}
185
-
}
186
-
187
-
// Type returns the document type, for bleve's mapping.Classifier interface.
188
-
func (i *pullData) Type() string {
189
-
return pullIndexerDocType
190
-
}
191
-
192
-
type pullCommentData struct {
193
-
Body string `json:"body"`
194
-
}
195
-
196
-
type searchResult struct {
197
-
Hits []int64
198
-
Total uint64
199
-
}
200
-
201
-
const maxBatchSize = 20
202
-
203
-
func (ix *Indexer) Index(ctx context.Context, pulls ...*models.Pull) error {
204
-
batch := bleveutil.NewFlushingBatch(ix.indexer, maxBatchSize)
205
-
for _, pull := range pulls {
206
-
pullData := makePullData(pull)
207
-
if err := batch.Index(base36.Encode(pullData.ID), pullData); err != nil {
208
-
return err
209
-
}
210
-
}
211
-
return batch.Flush()
212
-
}
213
-
214
-
func (ix *Indexer) Delete(ctx context.Context, pullID int64) error {
215
-
return ix.indexer.Delete(base36.Encode(pullID))
216
-
}
217
-
218
-
// Search searches for pulls
219
-
func (ix *Indexer) Search(ctx context.Context, opts models.PullSearchOptions) (*searchResult, error) {
220
-
var queries []query.Query
221
-
222
-
// TODO(boltless): remove this after implementing pulls page pagination
223
-
limit := opts.Page.Limit
224
-
if limit == 0 {
225
-
limit = 500
226
-
}
227
-
228
-
if opts.Keyword != "" {
229
-
queries = append(queries, bleve.NewDisjunctionQuery(
230
-
bleveutil.MatchAndQuery("title", opts.Keyword, pullIndexerAnalyzer, 0),
231
-
bleveutil.MatchAndQuery("body", opts.Keyword, pullIndexerAnalyzer, 0),
232
-
))
233
-
}
234
-
queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt))
235
-
queries = append(queries, bleveutil.KeywordFieldQuery("state", opts.State.String()))
236
-
237
-
var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...)
238
-
searchReq := bleve.NewSearchRequestOptions(indexerQuery, limit, opts.Page.Offset, false)
239
-
res, err := ix.indexer.SearchInContext(ctx, searchReq)
240
-
if err != nil {
241
-
return nil, nil
242
-
}
243
-
ret := &searchResult{
244
-
Total: res.Total,
245
-
Hits: make([]int64, len(res.Hits)),
246
-
}
247
-
for i, hit := range res.Hits {
248
-
id, err := base36.Decode(hit.ID)
249
-
if err != nil {
250
-
return nil, err
251
-
}
252
-
ret.Hits[i] = id
253
-
}
254
-
return ret, nil
255
-
}
+1
-7
appview/ingester.go
+1
-7
appview/ingester.go
···
89
89
}
90
90
91
91
if err != nil {
92
-
l.Warn("refused to ingest record", "err", err)
92
+
l.Debug("error ingesting record", "err", err)
93
93
}
94
94
95
95
return nil
···
291
291
292
292
includeBluesky := record.Bluesky
293
293
294
-
pronouns := ""
295
-
if record.Pronouns != nil {
296
-
pronouns = *record.Pronouns
297
-
}
298
-
299
294
location := ""
300
295
if record.Location != nil {
301
296
location = *record.Location
···
330
325
Links: links,
331
326
Stats: stats,
332
327
PinnedRepos: pinned,
333
-
Pronouns: pronouns,
334
328
}
335
329
336
330
ddb, ok := i.Db.Execer.(*db.DB)
+54
-113
appview/issues/issues.go
+54
-113
appview/issues/issues.go
···
5
5
"database/sql"
6
6
"errors"
7
7
"fmt"
8
+
"log"
8
9
"log/slog"
9
10
"net/http"
10
11
"slices"
11
12
"time"
12
13
13
14
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
-
atpclient "github.com/bluesky-social/indigo/atproto/client"
15
15
"github.com/bluesky-social/indigo/atproto/syntax"
16
16
lexutil "github.com/bluesky-social/indigo/lex/util"
17
17
"github.com/go-chi/chi/v5"
···
19
19
"tangled.org/core/api/tangled"
20
20
"tangled.org/core/appview/config"
21
21
"tangled.org/core/appview/db"
22
-
issues_indexer "tangled.org/core/appview/indexer/issues"
23
22
"tangled.org/core/appview/models"
24
23
"tangled.org/core/appview/notify"
25
24
"tangled.org/core/appview/oauth"
26
25
"tangled.org/core/appview/pages"
27
-
"tangled.org/core/appview/pages/markup"
28
26
"tangled.org/core/appview/pagination"
29
27
"tangled.org/core/appview/reporesolver"
30
28
"tangled.org/core/appview/validator"
29
+
"tangled.org/core/appview/xrpcclient"
31
30
"tangled.org/core/idresolver"
31
+
tlog "tangled.org/core/log"
32
32
"tangled.org/core/tid"
33
33
)
34
34
···
42
42
notifier notify.Notifier
43
43
logger *slog.Logger
44
44
validator *validator.Validator
45
-
indexer *issues_indexer.Indexer
46
45
}
47
46
48
47
func New(
···
54
53
config *config.Config,
55
54
notifier notify.Notifier,
56
55
validator *validator.Validator,
57
-
indexer *issues_indexer.Indexer,
58
-
logger *slog.Logger,
59
56
) *Issues {
60
57
return &Issues{
61
58
oauth: oauth,
···
65
62
db: db,
66
63
config: config,
67
64
notifier: notifier,
68
-
logger: logger,
65
+
logger: tlog.New("issues"),
69
66
validator: validator,
70
-
indexer: indexer,
71
67
}
72
68
}
73
69
···
76
72
user := rp.oauth.GetUser(r)
77
73
f, err := rp.repoResolver.Resolve(r)
78
74
if err != nil {
79
-
l.Error("failed to get repo and knot", "err", err)
75
+
log.Println("failed to get repo and knot", err)
80
76
return
81
77
}
82
78
···
87
83
return
88
84
}
89
85
90
-
reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri())
86
+
reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
91
87
if err != nil {
92
88
l.Error("failed to get issue reactions", "err", err)
93
89
}
···
103
99
db.FilterContains("scope", tangled.RepoIssueNSID),
104
100
)
105
101
if err != nil {
106
-
l.Error("failed to fetch labels", "err", err)
102
+
log.Println("failed to fetch labels", err)
107
103
rp.pages.Error503(w)
108
104
return
109
105
}
···
119
115
Issue: issue,
120
116
CommentList: issue.CommentList(),
121
117
OrderedReactionKinds: models.OrderedReactionKinds,
122
-
Reactions: reactionMap,
118
+
Reactions: reactionCountMap,
123
119
UserReacted: userReactions,
124
120
LabelDefs: defs,
125
121
})
···
130
126
user := rp.oauth.GetUser(r)
131
127
f, err := rp.repoResolver.Resolve(r)
132
128
if err != nil {
133
-
l.Error("failed to get repo and knot", "err", err)
129
+
log.Println("failed to get repo and knot", err)
134
130
return
135
131
}
136
132
···
170
166
return
171
167
}
172
168
173
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
169
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
174
170
if err != nil {
175
171
l.Error("failed to get record", "err", err)
176
172
rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
177
173
return
178
174
}
179
175
180
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
176
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
181
177
Collection: tangled.RepoIssueNSID,
182
178
Repo: user.Did,
183
179
Rkey: newIssue.Rkey,
···
203
199
204
200
err = db.PutIssue(tx, newIssue)
205
201
if err != nil {
206
-
l.Error("failed to edit issue", "err", err)
202
+
log.Println("failed to edit issue", err)
207
203
rp.pages.Notice(w, "issues", "Failed to edit issue.")
208
204
return
209
205
}
···
241
237
// delete from PDS
242
238
client, err := rp.oauth.AuthorizedClient(r)
243
239
if err != nil {
244
-
l.Error("failed to get authorized client", "err", err)
240
+
log.Println("failed to get authorized client", err)
245
241
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
246
242
return
247
243
}
248
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
244
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
249
245
Collection: tangled.RepoIssueNSID,
250
246
Repo: issue.Did,
251
247
Rkey: issue.Rkey,
···
264
260
return
265
261
}
266
262
267
-
rp.notifier.DeleteIssue(r.Context(), issue)
268
-
269
263
// return to all issues page
270
264
rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
271
265
}
···
288
282
289
283
collaborators, err := f.Collaborators(r.Context())
290
284
if err != nil {
291
-
l.Error("failed to fetch repo collaborators", "err", err)
285
+
log.Println("failed to fetch repo collaborators: %w", err)
292
286
}
293
287
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
294
288
return user.Did == collab.Did
···
302
296
db.FilterEq("id", issue.Id),
303
297
)
304
298
if err != nil {
305
-
l.Error("failed to close issue", "err", err)
299
+
log.Println("failed to close issue", err)
306
300
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
307
301
return
308
302
}
309
-
// change the issue state (this will pass down to the notifiers)
310
-
issue.Open = false
311
303
312
304
// notify about the issue closure
313
-
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
305
+
rp.notifier.NewIssueClosed(r.Context(), issue)
314
306
315
307
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
316
308
return
317
309
} else {
318
-
l.Error("user is not permitted to close issue")
310
+
log.Println("user is not permitted to close issue")
319
311
http.Error(w, "for biden", http.StatusUnauthorized)
320
312
return
321
313
}
···
326
318
user := rp.oauth.GetUser(r)
327
319
f, err := rp.repoResolver.Resolve(r)
328
320
if err != nil {
329
-
l.Error("failed to get repo and knot", "err", err)
321
+
log.Println("failed to get repo and knot", err)
330
322
return
331
323
}
332
324
···
339
331
340
332
collaborators, err := f.Collaborators(r.Context())
341
333
if err != nil {
342
-
l.Error("failed to fetch repo collaborators", "err", err)
334
+
log.Println("failed to fetch repo collaborators: %w", err)
343
335
}
344
336
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
345
337
return user.Did == collab.Did
···
352
344
db.FilterEq("id", issue.Id),
353
345
)
354
346
if err != nil {
355
-
l.Error("failed to reopen issue", "err", err)
347
+
log.Println("failed to reopen issue", err)
356
348
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
357
349
return
358
350
}
359
-
// change the issue state (this will pass down to the notifiers)
360
-
issue.Open = true
361
-
362
-
// notify about the issue reopen
363
-
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
364
-
365
351
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
366
352
return
367
353
} else {
368
-
l.Error("user is not the owner of the repo")
354
+
log.Println("user is not the owner of the repo")
369
355
http.Error(w, "forbidden", http.StatusUnauthorized)
370
356
return
371
357
}
···
422
408
}
423
409
424
410
// create a record first
425
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
411
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
426
412
Collection: tangled.RepoIssueCommentNSID,
427
413
Repo: comment.Did,
428
414
Rkey: comment.Rkey,
···
454
440
455
441
// notify about the new comment
456
442
comment.Id = commentId
457
-
458
-
rawMentions := markup.FindUserMentions(comment.Body)
459
-
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
460
-
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
461
-
var mentions []syntax.DID
462
-
for _, ident := range idents {
463
-
if ident != nil && !ident.Handle.IsInvalidHandle() {
464
-
mentions = append(mentions, ident.DID)
465
-
}
466
-
}
467
-
rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
443
+
rp.notifier.NewIssueComment(r.Context(), &comment)
468
444
469
445
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
470
446
}
···
562
538
newBody := r.FormValue("body")
563
539
client, err := rp.oauth.AuthorizedClient(r)
564
540
if err != nil {
565
-
l.Error("failed to get authorized client", "err", err)
541
+
log.Println("failed to get authorized client", err)
566
542
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
567
543
return
568
544
}
···
575
551
576
552
_, err = db.AddIssueComment(rp.db, newComment)
577
553
if err != nil {
578
-
l.Error("failed to perferom update-description query", "err", err)
554
+
log.Println("failed to perferom update-description query", err)
579
555
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
580
556
return
581
557
}
···
583
559
// rkey is optional, it was introduced later
584
560
if newComment.Rkey != "" {
585
561
// update the record on pds
586
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
562
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
587
563
if err != nil {
588
-
l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
564
+
log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
589
565
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
590
566
return
591
567
}
592
568
593
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
569
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
594
570
Collection: tangled.RepoIssueCommentNSID,
595
571
Repo: user.Did,
596
572
Rkey: newComment.Rkey,
···
753
729
if comment.Rkey != "" {
754
730
client, err := rp.oauth.AuthorizedClient(r)
755
731
if err != nil {
756
-
l.Error("failed to get authorized client", "err", err)
732
+
log.Println("failed to get authorized client", err)
757
733
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
758
734
return
759
735
}
760
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
736
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
761
737
Collection: tangled.RepoIssueCommentNSID,
762
738
Repo: user.Did,
763
739
Rkey: comment.Rkey,
764
740
})
765
741
if err != nil {
766
-
l.Error("failed to delete from PDS", "err", err)
742
+
log.Println(err)
767
743
}
768
744
}
769
745
···
781
757
}
782
758
783
759
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
784
-
l := rp.logger.With("handler", "RepoIssues")
785
-
786
760
params := r.URL.Query()
787
761
state := params.Get("state")
788
762
isOpen := true
···
795
769
isOpen = true
796
770
}
797
771
798
-
page := pagination.FromContext(r.Context())
772
+
page, ok := r.Context().Value("page").(pagination.Page)
773
+
if !ok {
774
+
log.Println("failed to get page")
775
+
page = pagination.FirstPage()
776
+
}
799
777
800
778
user := rp.oauth.GetUser(r)
801
779
f, err := rp.repoResolver.Resolve(r)
802
780
if err != nil {
803
-
l.Error("failed to get repo and knot", "err", err)
781
+
log.Println("failed to get repo and knot", err)
804
782
return
805
783
}
806
784
807
-
keyword := params.Get("q")
808
-
809
-
var ids []int64
810
-
searchOpts := models.IssueSearchOptions{
811
-
Keyword: keyword,
812
-
RepoAt: f.RepoAt().String(),
813
-
IsOpen: isOpen,
814
-
Page: page,
785
+
openVal := 0
786
+
if isOpen {
787
+
openVal = 1
815
788
}
816
-
if keyword != "" {
817
-
res, err := rp.indexer.Search(r.Context(), searchOpts)
818
-
if err != nil {
819
-
l.Error("failed to search for issues", "err", err)
820
-
return
821
-
}
822
-
ids = res.Hits
823
-
l.Debug("searched issues with indexer", "count", len(ids))
824
-
} else {
825
-
ids, err = db.GetIssueIDs(rp.db, searchOpts)
826
-
if err != nil {
827
-
l.Error("failed to search for issues", "err", err)
828
-
return
829
-
}
830
-
l.Debug("indexed all issues from the db", "count", len(ids))
831
-
}
832
-
833
-
issues, err := db.GetIssues(
789
+
issues, err := db.GetIssuesPaginated(
834
790
rp.db,
835
-
db.FilterIn("id", ids),
791
+
page,
792
+
db.FilterEq("repo_at", f.RepoAt()),
793
+
db.FilterEq("open", openVal),
836
794
)
837
795
if err != nil {
838
-
l.Error("failed to get issues", "err", err)
796
+
log.Println("failed to get issues", err)
839
797
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
840
798
return
841
799
}
842
800
843
-
labelDefs, err := db.GetLabelDefinitions(
844
-
rp.db,
845
-
db.FilterIn("at_uri", f.Repo.Labels),
846
-
db.FilterContains("scope", tangled.RepoIssueNSID),
847
-
)
801
+
labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
848
802
if err != nil {
849
-
l.Error("failed to fetch labels", "err", err)
803
+
log.Println("failed to fetch labels", err)
850
804
rp.pages.Error503(w)
851
805
return
852
806
}
···
862
816
Issues: issues,
863
817
LabelDefs: defs,
864
818
FilteringByOpen: isOpen,
865
-
FilterQuery: keyword,
866
819
Page: page,
867
820
})
868
821
}
···
889
842
Rkey: tid.TID(),
890
843
Title: r.FormValue("title"),
891
844
Body: r.FormValue("body"),
892
-
Open: true,
893
845
Did: user.Did,
894
846
Created: time.Now(),
895
-
Repo: &f.Repo,
896
847
}
897
848
898
849
if err := rp.validator.ValidateIssue(issue); err != nil {
···
910
861
rp.pages.Notice(w, "issues", "Failed to create issue.")
911
862
return
912
863
}
913
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
864
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
914
865
Collection: tangled.RepoIssueNSID,
915
866
Repo: user.Did,
916
867
Rkey: issue.Rkey,
···
946
897
947
898
err = db.PutIssue(tx, issue)
948
899
if err != nil {
949
-
l.Error("failed to create issue", "err", err)
900
+
log.Println("failed to create issue", err)
950
901
rp.pages.Notice(w, "issues", "Failed to create issue.")
951
902
return
952
903
}
953
904
954
905
if err = tx.Commit(); err != nil {
955
-
l.Error("failed to create issue", "err", err)
906
+
log.Println("failed to create issue", err)
956
907
rp.pages.Notice(w, "issues", "Failed to create issue.")
957
908
return
958
909
}
959
910
960
911
// everything is successful, do not rollback the atproto record
961
912
atUri = ""
962
-
963
-
rawMentions := markup.FindUserMentions(issue.Body)
964
-
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
965
-
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
966
-
var mentions []syntax.DID
967
-
for _, ident := range idents {
968
-
if ident != nil && !ident.Handle.IsInvalidHandle() {
969
-
mentions = append(mentions, ident.DID)
970
-
}
971
-
}
972
-
rp.notifier.NewIssue(r.Context(), issue, mentions)
913
+
rp.notifier.NewIssue(r.Context(), issue)
973
914
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
974
915
return
975
916
}
···
978
919
// this is used to rollback changes made to the PDS
979
920
//
980
921
// it is a no-op if the provided ATURI is empty
981
-
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
922
+
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
982
923
if aturi == "" {
983
924
return nil
984
925
}
···
989
930
repo := parsed.Authority().String()
990
931
rkey := parsed.RecordKey().String()
991
932
992
-
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
933
+
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
993
934
Collection: collection,
994
935
Repo: repo,
995
936
Rkey: rkey,
-267
appview/issues/opengraph.go
-267
appview/issues/opengraph.go
···
1
-
package issues
2
-
3
-
import (
4
-
"bytes"
5
-
"context"
6
-
"fmt"
7
-
"image"
8
-
"image/color"
9
-
"image/png"
10
-
"log"
11
-
"net/http"
12
-
13
-
"tangled.org/core/appview/models"
14
-
"tangled.org/core/appview/ogcard"
15
-
)
16
-
17
-
func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) {
18
-
width, height := ogcard.DefaultSize()
19
-
mainCard, err := ogcard.NewCard(width, height)
20
-
if err != nil {
21
-
return nil, err
22
-
}
23
-
24
-
// Split: content area (75%) and status/stats area (25%)
25
-
contentCard, statsArea := mainCard.Split(false, 75)
26
-
27
-
// Add padding to content
28
-
contentCard.SetMargin(50)
29
-
30
-
// Split content horizontally: main content (80%) and avatar area (20%)
31
-
mainContent, avatarArea := contentCard.Split(true, 80)
32
-
33
-
// Add margin to main content like repo card
34
-
mainContent.SetMargin(10)
35
-
36
-
// Use full main content area for repo name and title
37
-
bounds := mainContent.Img.Bounds()
38
-
startX := bounds.Min.X + mainContent.Margin
39
-
startY := bounds.Min.Y + mainContent.Margin
40
-
41
-
// Draw full repository name at top (owner/repo format)
42
-
var repoOwner string
43
-
owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did)
44
-
if err != nil {
45
-
repoOwner = repo.Did
46
-
} else {
47
-
repoOwner = "@" + owner.Handle.String()
48
-
}
49
-
50
-
fullRepoName := repoOwner + " / " + repo.Name
51
-
if len(fullRepoName) > 60 {
52
-
fullRepoName = fullRepoName[:60] + "…"
53
-
}
54
-
55
-
grayColor := color.RGBA{88, 96, 105, 255}
56
-
err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left)
57
-
if err != nil {
58
-
return nil, err
59
-
}
60
-
61
-
// Draw issue title below repo name with wrapping
62
-
titleY := startY + 60
63
-
titleX := startX
64
-
65
-
// Truncate title if too long
66
-
issueTitle := issue.Title
67
-
maxTitleLength := 80
68
-
if len(issueTitle) > maxTitleLength {
69
-
issueTitle = issueTitle[:maxTitleLength] + "…"
70
-
}
71
-
72
-
// Create a temporary card for the title area to enable wrapping
73
-
titleBounds := mainContent.Img.Bounds()
74
-
titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin
75
-
titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID
76
-
77
-
titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight)
78
-
titleCard := &ogcard.Card{
79
-
Img: mainContent.Img.SubImage(titleRect).(*image.RGBA),
80
-
Font: mainContent.Font,
81
-
Margin: 0,
82
-
}
83
-
84
-
// Draw wrapped title
85
-
lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left)
86
-
if err != nil {
87
-
return nil, err
88
-
}
89
-
90
-
// Calculate where title ends (number of lines * line height)
91
-
lineHeight := 60 // Approximate line height for 54pt font
92
-
titleEndY := titleY + (len(lines) * lineHeight) + 10
93
-
94
-
// Draw issue ID in gray below the title
95
-
issueIdText := fmt.Sprintf("#%d", issue.IssueId)
96
-
err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left)
97
-
if err != nil {
98
-
return nil, err
99
-
}
100
-
101
-
// Get issue author handle (needed for avatar and metadata)
102
-
var authorHandle string
103
-
author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did)
104
-
if err != nil {
105
-
authorHandle = issue.Did
106
-
} else {
107
-
authorHandle = "@" + author.Handle.String()
108
-
}
109
-
110
-
// Draw avatar circle on the right side
111
-
avatarBounds := avatarArea.Img.Bounds()
112
-
avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
113
-
if avatarSize > 220 {
114
-
avatarSize = 220
115
-
}
116
-
avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
117
-
avatarY := avatarBounds.Min.Y + 20
118
-
119
-
// Get avatar URL for issue author
120
-
avatarURL := rp.pages.AvatarUrl(authorHandle, "256")
121
-
err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
122
-
if err != nil {
123
-
log.Printf("failed to draw avatar (non-fatal): %v", err)
124
-
}
125
-
126
-
// Split stats area: left side for status/comments (80%), right side for dolly (20%)
127
-
statusCommentsArea, dollyArea := statsArea.Split(true, 80)
128
-
129
-
// Draw status and comment count in status/comments area
130
-
statsBounds := statusCommentsArea.Img.Bounds()
131
-
statsX := statsBounds.Min.X + 60 // left padding
132
-
statsY := statsBounds.Min.Y
133
-
134
-
iconColor := color.RGBA{88, 96, 105, 255}
135
-
iconSize := 36
136
-
textSize := 36.0
137
-
labelSize := 28.0
138
-
iconBaselineOffset := int(textSize) / 2
139
-
140
-
// Draw status (open/closed) with colored icon and text
141
-
var statusIcon string
142
-
var statusText string
143
-
var statusBgColor color.RGBA
144
-
145
-
if issue.Open {
146
-
statusIcon = "circle-dot"
147
-
statusText = "open"
148
-
statusBgColor = color.RGBA{34, 139, 34, 255} // green
149
-
} else {
150
-
statusIcon = "ban"
151
-
statusText = "closed"
152
-
statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray
153
-
}
154
-
155
-
badgeIconSize := 36
156
-
157
-
// Draw icon with status color (no background)
158
-
err = statusCommentsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor)
159
-
if err != nil {
160
-
log.Printf("failed to draw status icon: %v", err)
161
-
}
162
-
163
-
// Draw text with status color (no background)
164
-
textX := statsX + badgeIconSize + 12
165
-
badgeTextSize := 32.0
166
-
err = statusCommentsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusBgColor, badgeTextSize, ogcard.Middle, ogcard.Left)
167
-
if err != nil {
168
-
log.Printf("failed to draw status text: %v", err)
169
-
}
170
-
171
-
statusTextWidth := len(statusText) * 20
172
-
currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50
173
-
174
-
// Draw comment count
175
-
err = statusCommentsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
176
-
if err != nil {
177
-
log.Printf("failed to draw comment icon: %v", err)
178
-
}
179
-
180
-
currentX += iconSize + 15
181
-
commentText := fmt.Sprintf("%d comments", commentCount)
182
-
if commentCount == 1 {
183
-
commentText = "1 comment"
184
-
}
185
-
err = statusCommentsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
186
-
if err != nil {
187
-
log.Printf("failed to draw comment text: %v", err)
188
-
}
189
-
190
-
// Draw dolly logo on the right side
191
-
dollyBounds := dollyArea.Img.Bounds()
192
-
dollySize := 90
193
-
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194
-
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195
-
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196
-
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
197
-
if err != nil {
198
-
log.Printf("dolly silhouette not available (this is ok): %v", err)
199
-
}
200
-
201
-
// Draw "opened by @author" and date at the bottom with more spacing
202
-
labelY := statsY + iconSize + 30
203
-
204
-
// Format the opened date
205
-
openedDate := issue.Created.Format("Jan 2, 2006")
206
-
metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
207
-
208
-
err = statusCommentsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
209
-
if err != nil {
210
-
log.Printf("failed to draw metadata: %v", err)
211
-
}
212
-
213
-
return mainCard, nil
214
-
}
215
-
216
-
func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
217
-
f, err := rp.repoResolver.Resolve(r)
218
-
if err != nil {
219
-
log.Println("failed to get repo and knot", err)
220
-
return
221
-
}
222
-
223
-
issue, ok := r.Context().Value("issue").(*models.Issue)
224
-
if !ok {
225
-
log.Println("issue not found in context")
226
-
http.Error(w, "issue not found", http.StatusNotFound)
227
-
return
228
-
}
229
-
230
-
// Get comment count
231
-
commentCount := len(issue.Comments)
232
-
233
-
// Get owner handle for avatar
234
-
var ownerHandle string
235
-
owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did)
236
-
if err != nil {
237
-
ownerHandle = f.Repo.Did
238
-
} else {
239
-
ownerHandle = "@" + owner.Handle.String()
240
-
}
241
-
242
-
card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle)
243
-
if err != nil {
244
-
log.Println("failed to draw issue summary card", err)
245
-
http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
246
-
return
247
-
}
248
-
249
-
var imageBuffer bytes.Buffer
250
-
err = png.Encode(&imageBuffer, card.Img)
251
-
if err != nil {
252
-
log.Println("failed to encode issue summary card", err)
253
-
http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError)
254
-
return
255
-
}
256
-
257
-
imageBytes := imageBuffer.Bytes()
258
-
259
-
w.Header().Set("Content-Type", "image/png")
260
-
w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
261
-
w.WriteHeader(http.StatusOK)
262
-
_, err = w.Write(imageBytes)
263
-
if err != nil {
264
-
log.Println("failed to write issue summary card", err)
265
-
return
266
-
}
267
-
}
-1
appview/issues/router.go
-1
appview/issues/router.go
+6
-15
appview/knots/knots.go
+6
-15
appview/knots/knots.go
···
6
6
"log/slog"
7
7
"net/http"
8
8
"slices"
9
-
"strings"
10
9
"time"
11
10
12
11
"github.com/go-chi/chi/v5"
···
146
145
}
147
146
148
147
domain := r.FormValue("domain")
149
-
// Strip protocol, trailing slashes, and whitespace
150
-
// Rkey cannot contain slashes
151
-
domain = strings.TrimSpace(domain)
152
-
domain = strings.TrimPrefix(domain, "https://")
153
-
domain = strings.TrimPrefix(domain, "http://")
154
-
domain = strings.TrimSuffix(domain, "/")
155
148
if domain == "" {
156
149
k.Pages.Notice(w, noticeId, "Incomplete form.")
157
150
return
···
192
185
return
193
186
}
194
187
195
-
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain)
188
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
196
189
var exCid *string
197
190
if ex != nil {
198
191
exCid = ex.Cid
199
192
}
200
193
201
194
// re-announce by registering under same rkey
202
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
195
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
203
196
Collection: tangled.KnotNSID,
204
197
Repo: user.Did,
205
198
Rkey: domain,
···
330
323
return
331
324
}
332
325
333
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
326
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
334
327
Collection: tangled.KnotNSID,
335
328
Repo: user.Did,
336
329
Rkey: domain,
···
438
431
return
439
432
}
440
433
441
-
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain)
434
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
442
435
var exCid *string
443
436
if ex != nil {
444
437
exCid = ex.Cid
445
438
}
446
439
447
440
// ignore the error here
448
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
441
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
449
442
Collection: tangled.KnotNSID,
450
443
Repo: user.Did,
451
444
Rkey: domain,
···
533
526
}
534
527
535
528
member := r.FormValue("member")
536
-
member = strings.TrimPrefix(member, "@")
537
529
if member == "" {
538
530
l.Error("empty member")
539
531
k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
563
555
564
556
rkey := tid.TID()
565
557
566
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
558
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
567
559
Collection: tangled.KnotMemberNSID,
568
560
Repo: user.Did,
569
561
Rkey: rkey,
···
634
626
}
635
627
636
628
member := r.FormValue("member")
637
-
member = strings.TrimPrefix(member, "@")
638
629
if member == "" {
639
630
l.Error("empty member")
640
631
k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+13
-11
appview/labels/labels.go
+13
-11
appview/labels/labels.go
···
9
9
"net/http"
10
10
"time"
11
11
12
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
lexutil "github.com/bluesky-social/indigo/lex/util"
15
+
"github.com/go-chi/chi/v5"
16
+
12
17
"tangled.org/core/api/tangled"
13
18
"tangled.org/core/appview/db"
14
19
"tangled.org/core/appview/middleware"
···
16
21
"tangled.org/core/appview/oauth"
17
22
"tangled.org/core/appview/pages"
18
23
"tangled.org/core/appview/validator"
24
+
"tangled.org/core/appview/xrpcclient"
25
+
"tangled.org/core/log"
19
26
"tangled.org/core/rbac"
20
27
"tangled.org/core/tid"
21
-
22
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
23
-
atpclient "github.com/bluesky-social/indigo/atproto/client"
24
-
"github.com/bluesky-social/indigo/atproto/syntax"
25
-
lexutil "github.com/bluesky-social/indigo/lex/util"
26
-
"github.com/go-chi/chi/v5"
27
28
)
28
29
29
30
type Labels struct {
···
41
42
db *db.DB,
42
43
validator *validator.Validator,
43
44
enforcer *rbac.Enforcer,
44
-
logger *slog.Logger,
45
45
) *Labels {
46
+
logger := log.New("labels")
47
+
46
48
return &Labels{
47
49
oauth: oauth,
48
50
pages: pages,
···
53
55
}
54
56
}
55
57
56
-
func (l *Labels) Router() http.Handler {
58
+
func (l *Labels) Router(mw *middleware.Middleware) http.Handler {
57
59
r := chi.NewRouter()
58
60
59
61
r.Use(middleware.AuthMiddleware(l.oauth))
···
194
196
return
195
197
}
196
198
197
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
199
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
198
200
Collection: tangled.LabelOpNSID,
199
201
Repo: did,
200
202
Rkey: rkey,
···
250
252
// this is used to rollback changes made to the PDS
251
253
//
252
254
// it is a no-op if the provided ATURI is empty
253
-
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
255
+
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
254
256
if aturi == "" {
255
257
return nil
256
258
}
···
261
263
repo := parsed.Authority().String()
262
264
rkey := parsed.RecordKey().String()
263
265
264
-
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
266
+
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
265
267
Collection: collection,
266
268
Repo: repo,
267
269
Rkey: rkey,
+30
-16
appview/middleware/middleware.go
+30
-16
appview/middleware/middleware.go
···
43
43
44
44
type middlewareFunc func(http.Handler) http.Handler
45
45
46
-
func AuthMiddleware(o *oauth.OAuth) middlewareFunc {
46
+
func (mw *Middleware) TryRefreshSession() middlewareFunc {
47
+
return func(next http.Handler) http.Handler {
48
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
+
_, _, _ = mw.oauth.GetSession(r)
50
+
next.ServeHTTP(w, r)
51
+
})
52
+
}
53
+
}
54
+
55
+
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
47
56
return func(next http.Handler) http.Handler {
48
57
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
58
returnURL := "/"
···
63
72
}
64
73
}
65
74
66
-
sess, err := o.ResumeSession(r)
75
+
_, auth, err := a.GetSession(r)
67
76
if err != nil {
68
-
log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String())
77
+
log.Println("not logged in, redirecting", "err", err)
69
78
redirectFunc(w, r)
70
79
return
71
80
}
72
81
73
-
if sess == nil {
74
-
log.Printf("session is nil, redirecting...")
82
+
if !auth {
83
+
log.Printf("not logged in, redirecting")
75
84
redirectFunc(w, r)
76
85
return
77
86
}
···
105
114
}
106
115
}
107
116
108
-
ctx := pagination.IntoContext(r.Context(), page)
117
+
ctx := context.WithValue(r.Context(), "page", page)
109
118
next.ServeHTTP(w, r.WithContext(ctx))
110
119
})
111
120
}
···
180
189
return func(next http.Handler) http.Handler {
181
190
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
182
191
didOrHandle := chi.URLParam(req, "user")
183
-
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
184
-
185
192
if slices.Contains(excluded, didOrHandle) {
186
193
next.ServeHTTP(w, req)
187
194
return
188
195
}
189
196
197
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
198
+
190
199
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
191
200
if err != nil {
192
201
// invalid did or handle
···
206
215
return func(next http.Handler) http.Handler {
207
216
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
208
217
repoName := chi.URLParam(req, "repo")
209
-
repoName = strings.TrimSuffix(repoName, ".git")
210
-
211
218
id, ok := req.Context().Value("resolvedId").(identity.Identity)
212
219
if !ok {
213
220
log.Println("malformed middleware")
···
246
253
prId := chi.URLParam(r, "pull")
247
254
prIdInt, err := strconv.Atoi(prId)
248
255
if err != nil {
256
+
http.Error(w, "bad pr id", http.StatusBadRequest)
249
257
log.Println("failed to parse pr id", err)
250
-
mw.pages.Error404(w)
251
258
return
252
259
}
253
260
254
261
pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt)
255
262
if err != nil {
256
263
log.Println("failed to get pull and comments", err)
257
-
mw.pages.Error404(w)
258
264
return
259
265
}
260
266
···
295
301
issueId, err := strconv.Atoi(issueIdStr)
296
302
if err != nil {
297
303
log.Println("failed to fully resolve issue ID", err)
298
-
mw.pages.Error404(w)
304
+
mw.pages.ErrorKnot404(w)
299
305
return
300
306
}
301
307
302
-
issue, err := db.GetIssue(mw.db, f.RepoAt(), issueId)
308
+
issues, err := db.GetIssues(
309
+
mw.db,
310
+
db.FilterEq("repo_at", f.RepoAt()),
311
+
db.FilterEq("issue_id", issueId),
312
+
)
303
313
if err != nil {
304
314
log.Println("failed to get issues", "err", err)
305
-
mw.pages.Error404(w)
315
+
return
316
+
}
317
+
if len(issues) != 1 {
318
+
log.Println("got incorrect number of issues", "len(issuse)", len(issues))
306
319
return
307
320
}
321
+
issue := issues[0]
308
322
309
-
ctx := context.WithValue(r.Context(), "issue", issue)
323
+
ctx := context.WithValue(r.Context(), "issue", &issue)
310
324
next.ServeHTTP(w, r.WithContext(ctx))
311
325
})
312
326
}
-24
appview/models/issue.go
-24
appview/models/issue.go
···
54
54
Replies []*IssueComment
55
55
}
56
56
57
-
func (it *CommentListItem) Participants() []syntax.DID {
58
-
participantSet := make(map[syntax.DID]struct{})
59
-
participants := []syntax.DID{}
60
-
61
-
addParticipant := func(did syntax.DID) {
62
-
if _, exists := participantSet[did]; !exists {
63
-
participantSet[did] = struct{}{}
64
-
participants = append(participants, did)
65
-
}
66
-
}
67
-
68
-
addParticipant(syntax.DID(it.Self.Did))
69
-
70
-
for _, c := range it.Replies {
71
-
addParticipant(syntax.DID(c.Did))
72
-
}
73
-
74
-
return participants
75
-
}
76
-
77
57
func (i *Issue) CommentList() []CommentListItem {
78
58
// Create a map to quickly find comments by their aturi
79
59
toplevel := make(map[string]*CommentListItem)
···
187
167
188
168
func (i *IssueComment) IsTopLevel() bool {
189
169
return i.ReplyTo == nil
190
-
}
191
-
192
-
func (i *IssueComment) IsReply() bool {
193
-
return i.ReplyTo != nil
194
170
}
195
171
196
172
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+42
-25
appview/models/label.go
+42
-25
appview/models/label.go
···
14
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
15
"github.com/bluesky-social/indigo/xrpc"
16
16
"tangled.org/core/api/tangled"
17
+
"tangled.org/core/consts"
17
18
"tangled.org/core/idresolver"
18
19
)
19
20
···
460
461
return result
461
462
}
462
463
463
-
func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) {
464
-
var labelDefs []LabelDefinition
465
-
ctx := context.Background()
464
+
func DefaultLabelDefs() []string {
465
+
rkeys := []string{
466
+
"wontfix",
467
+
"duplicate",
468
+
"assignee",
469
+
"good-first-issue",
470
+
"documentation",
471
+
}
466
472
467
-
for _, dl := range aturis {
468
-
atUri, err := syntax.ParseATURI(dl)
469
-
if err != nil {
470
-
return nil, fmt.Errorf("failed to parse AT-URI %s: %v", dl, err)
471
-
}
472
-
if atUri.Collection() != tangled.LabelDefinitionNSID {
473
-
return nil, fmt.Errorf("expected AT-URI pointing %s collection: %s", tangled.LabelDefinitionNSID, atUri)
474
-
}
473
+
defs := make([]string, len(rkeys))
474
+
for i, r := range rkeys {
475
+
defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r)
476
+
}
475
477
476
-
owner, err := r.ResolveIdent(ctx, atUri.Authority().String())
477
-
if err != nil {
478
-
return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err)
479
-
}
478
+
return defs
479
+
}
480
480
481
-
xrpcc := xrpc.Client{
482
-
Host: owner.PDSEndpoint(),
483
-
}
481
+
func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
482
+
resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid)
483
+
if err != nil {
484
+
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err)
485
+
}
486
+
pdsEndpoint := resolved.PDSEndpoint()
487
+
if pdsEndpoint == "" {
488
+
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid)
489
+
}
490
+
client := &xrpc.Client{
491
+
Host: pdsEndpoint,
492
+
}
484
493
494
+
var labelDefs []LabelDefinition
495
+
496
+
for _, dl := range DefaultLabelDefs() {
497
+
atUri := syntax.ATURI(dl)
498
+
parsedUri, err := syntax.ParseATURI(string(atUri))
499
+
if err != nil {
500
+
return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err)
501
+
}
485
502
record, err := atproto.RepoGetRecord(
486
-
ctx,
487
-
&xrpcc,
503
+
context.Background(),
504
+
client,
488
505
"",
489
-
atUri.Collection().String(),
490
-
atUri.Authority().String(),
491
-
atUri.RecordKey().String(),
506
+
parsedUri.Collection().String(),
507
+
parsedUri.Authority().String(),
508
+
parsedUri.RecordKey().String(),
492
509
)
493
510
if err != nil {
494
511
return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
···
508
525
}
509
526
510
527
labelDef, err := LabelDefinitionFromRecord(
511
-
atUri.Authority().String(),
512
-
atUri.RecordKey().String(),
528
+
parsedUri.Authority().String(),
529
+
parsedUri.RecordKey().String(),
513
530
labelRecord,
514
531
)
515
532
if err != nil {
+1
-60
appview/models/notifications.go
+1
-60
appview/models/notifications.go
···
2
2
3
3
import (
4
4
"time"
5
-
6
-
"github.com/bluesky-social/indigo/atproto/syntax"
7
5
)
8
6
9
7
type NotificationType string
···
17
15
NotificationTypeFollowed NotificationType = "followed"
18
16
NotificationTypePullMerged NotificationType = "pull_merged"
19
17
NotificationTypeIssueClosed NotificationType = "issue_closed"
20
-
NotificationTypeIssueReopen NotificationType = "issue_reopen"
21
18
NotificationTypePullClosed NotificationType = "pull_closed"
22
-
NotificationTypePullReopen NotificationType = "pull_reopen"
23
-
NotificationTypeUserMentioned NotificationType = "user_mentioned"
24
19
)
25
20
26
21
type Notification struct {
···
50
45
return "message-square"
51
46
case NotificationTypeIssueClosed:
52
47
return "ban"
53
-
case NotificationTypeIssueReopen:
54
-
return "circle-dot"
55
48
case NotificationTypePullCreated:
56
49
return "git-pull-request-create"
57
50
case NotificationTypePullCommented:
···
60
53
return "git-merge"
61
54
case NotificationTypePullClosed:
62
55
return "git-pull-request-closed"
63
-
case NotificationTypePullReopen:
64
-
return "git-pull-request-create"
65
56
case NotificationTypeFollowed:
66
57
return "user-plus"
67
-
case NotificationTypeUserMentioned:
68
-
return "at-sign"
69
58
default:
70
59
return ""
71
60
}
···
80
69
81
70
type NotificationPreferences struct {
82
71
ID int64
83
-
UserDid syntax.DID
72
+
UserDid string
84
73
RepoStarred bool
85
74
IssueCreated bool
86
75
IssueCommented bool
87
76
PullCreated bool
88
77
PullCommented bool
89
78
Followed bool
90
-
UserMentioned bool
91
79
PullMerged bool
92
80
IssueClosed bool
93
81
EmailNotifications bool
94
82
}
95
-
96
-
func (prefs *NotificationPreferences) ShouldNotify(t NotificationType) bool {
97
-
switch t {
98
-
case NotificationTypeRepoStarred:
99
-
return prefs.RepoStarred
100
-
case NotificationTypeIssueCreated:
101
-
return prefs.IssueCreated
102
-
case NotificationTypeIssueCommented:
103
-
return prefs.IssueCommented
104
-
case NotificationTypeIssueClosed:
105
-
return prefs.IssueClosed
106
-
case NotificationTypeIssueReopen:
107
-
return prefs.IssueCreated // smae pref for now
108
-
case NotificationTypePullCreated:
109
-
return prefs.PullCreated
110
-
case NotificationTypePullCommented:
111
-
return prefs.PullCommented
112
-
case NotificationTypePullMerged:
113
-
return prefs.PullMerged
114
-
case NotificationTypePullClosed:
115
-
return prefs.PullMerged // same pref for now
116
-
case NotificationTypePullReopen:
117
-
return prefs.PullCreated // same pref for now
118
-
case NotificationTypeFollowed:
119
-
return prefs.Followed
120
-
case NotificationTypeUserMentioned:
121
-
return prefs.UserMentioned
122
-
default:
123
-
return false
124
-
}
125
-
}
126
-
127
-
func DefaultNotificationPreferences(user syntax.DID) *NotificationPreferences {
128
-
return &NotificationPreferences{
129
-
UserDid: user,
130
-
RepoStarred: true,
131
-
IssueCreated: true,
132
-
IssueCommented: true,
133
-
PullCreated: true,
134
-
PullCommented: true,
135
-
Followed: true,
136
-
UserMentioned: true,
137
-
PullMerged: true,
138
-
IssueClosed: true,
139
-
EmailNotifications: false,
140
-
}
141
-
}
-1
appview/models/profile.go
-1
appview/models/profile.go
+28
-77
appview/models/pull.go
+28
-77
appview/models/pull.go
···
77
77
PullSource *PullSource
78
78
79
79
// optionally, populate this when querying for reverse mappings
80
-
Labels LabelState
81
-
Repo *Repo
80
+
Repo *Repo
82
81
}
83
82
84
83
func (p Pull) AsRecord() tangled.RepoPull {
85
84
var source *tangled.RepoPull_Source
86
85
if p.PullSource != nil {
87
-
source = &tangled.RepoPull_Source{}
88
-
source.Branch = p.PullSource.Branch
86
+
s := p.PullSource.AsRecord()
87
+
source = &s
89
88
source.Sha = p.LatestSha()
90
-
if p.PullSource.RepoAt != nil {
91
-
s := p.PullSource.RepoAt.String()
92
-
source.Repo = &s
93
-
}
94
89
}
95
90
96
91
record := tangled.RepoPull{
···
115
110
Repo *Repo
116
111
}
117
112
113
+
func (p PullSource) AsRecord() tangled.RepoPull_Source {
114
+
var repoAt *string
115
+
if p.RepoAt != nil {
116
+
s := p.RepoAt.String()
117
+
repoAt = &s
118
+
}
119
+
record := tangled.RepoPull_Source{
120
+
Branch: p.Branch,
121
+
Repo: repoAt,
122
+
}
123
+
return record
124
+
}
125
+
118
126
type PullSubmission struct {
119
127
// ids
120
-
ID int
128
+
ID int
129
+
PullId int
121
130
122
131
// at ids
123
-
PullAt syntax.ATURI
132
+
RepoAt syntax.ATURI
124
133
125
134
// content
126
135
RoundNumber int
127
136
Patch string
128
-
Combined string
129
137
Comments []PullComment
130
138
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
131
139
···
151
159
Created time.Time
152
160
}
153
161
154
-
func (p *Pull) LastRoundNumber() int {
155
-
return len(p.Submissions) - 1
156
-
}
157
-
158
-
func (p *Pull) LatestSubmission() *PullSubmission {
159
-
return p.Submissions[p.LastRoundNumber()]
160
-
}
161
-
162
162
func (p *Pull) LatestPatch() string {
163
-
return p.LatestSubmission().Patch
163
+
latestSubmission := p.Submissions[p.LastRoundNumber()]
164
+
return latestSubmission.Patch
164
165
}
165
166
166
167
func (p *Pull) LatestSha() string {
167
-
return p.LatestSubmission().SourceRev
168
+
latestSubmission := p.Submissions[p.LastRoundNumber()]
169
+
return latestSubmission.SourceRev
168
170
}
169
171
170
-
func (p *Pull) AtUri() syntax.ATURI {
172
+
func (p *Pull) PullAt() syntax.ATURI {
171
173
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
174
+
}
175
+
176
+
func (p *Pull) LastRoundNumber() int {
177
+
return len(p.Submissions) - 1
172
178
}
173
179
174
180
func (p *Pull) IsPatchBased() bool {
···
201
207
return p.StackId != ""
202
208
}
203
209
204
-
func (p *Pull) Participants() []string {
205
-
participantSet := make(map[string]struct{})
206
-
participants := []string{}
207
-
208
-
addParticipant := func(did string) {
209
-
if _, exists := participantSet[did]; !exists {
210
-
participantSet[did] = struct{}{}
211
-
participants = append(participants, did)
212
-
}
213
-
}
214
-
215
-
addParticipant(p.OwnerDid)
216
-
217
-
for _, s := range p.Submissions {
218
-
for _, sp := range s.Participants() {
219
-
addParticipant(sp)
220
-
}
221
-
}
222
-
223
-
return participants
224
-
}
225
-
226
210
func (s PullSubmission) IsFormatPatch() bool {
227
211
return patchutil.IsFormatPatch(s.Patch)
228
212
}
···
235
219
}
236
220
237
221
return patches
238
-
}
239
-
240
-
func (s *PullSubmission) Participants() []string {
241
-
participantSet := make(map[string]struct{})
242
-
participants := []string{}
243
-
244
-
addParticipant := func(did string) {
245
-
if _, exists := participantSet[did]; !exists {
246
-
participantSet[did] = struct{}{}
247
-
participants = append(participants, did)
248
-
}
249
-
}
250
-
251
-
addParticipant(s.PullAt.Authority().String())
252
-
253
-
for _, c := range s.Comments {
254
-
addParticipant(c.OwnerDid)
255
-
}
256
-
257
-
return participants
258
-
}
259
-
260
-
func (s PullSubmission) CombinedPatch() string {
261
-
if s.Combined == "" {
262
-
return s.Patch
263
-
}
264
-
265
-
return s.Combined
266
222
}
267
223
268
224
type Stack []*Pull
···
352
308
353
309
return mergeable
354
310
}
355
-
356
-
type BranchDeleteStatus struct {
357
-
Repo *Repo
358
-
Branch string
359
-
}
-5
appview/models/reaction.go
-5
appview/models/reaction.go
+1
-66
appview/models/repo.go
+1
-66
appview/models/repo.go
···
2
2
3
3
import (
4
4
"fmt"
5
-
"strings"
6
5
"time"
7
6
8
7
"github.com/bluesky-social/indigo/atproto/syntax"
···
18
17
Rkey string
19
18
Created time.Time
20
19
Description string
21
-
Website string
22
-
Topics []string
23
20
Spindle string
24
21
Labels []string
25
22
···
31
28
}
32
29
33
30
func (r *Repo) AsRecord() tangled.Repo {
34
-
var source, spindle, description, website *string
31
+
var source, spindle, description *string
35
32
36
33
if r.Source != "" {
37
34
source = &r.Source
···
43
40
44
41
if r.Description != "" {
45
42
description = &r.Description
46
-
}
47
-
48
-
if r.Website != "" {
49
-
website = &r.Website
50
43
}
51
44
52
45
return tangled.Repo{
53
46
Knot: r.Knot,
54
47
Name: r.Name,
55
48
Description: description,
56
-
Website: website,
57
-
Topics: r.Topics,
58
49
CreatedAt: r.Created.Format(time.RFC3339),
59
50
Source: source,
60
51
Spindle: spindle,
···
69
60
func (r Repo) DidSlashRepo() string {
70
61
p, _ := securejoin.SecureJoin(r.Did, r.Name)
71
62
return p
72
-
}
73
-
74
-
func (r Repo) TopicStr() string {
75
-
return strings.Join(r.Topics, " ")
76
63
}
77
64
78
65
type RepoStats struct {
···
99
86
RepoAt syntax.ATURI
100
87
LabelAt syntax.ATURI
101
88
}
102
-
103
-
type RepoGroup struct {
104
-
Repo *Repo
105
-
Issues []Issue
106
-
}
107
-
108
-
type BlobContentType int
109
-
110
-
const (
111
-
BlobContentTypeCode BlobContentType = iota
112
-
BlobContentTypeMarkup
113
-
BlobContentTypeImage
114
-
BlobContentTypeSvg
115
-
BlobContentTypeVideo
116
-
BlobContentTypeSubmodule
117
-
)
118
-
119
-
func (ty BlobContentType) IsCode() bool { return ty == BlobContentTypeCode }
120
-
func (ty BlobContentType) IsMarkup() bool { return ty == BlobContentTypeMarkup }
121
-
func (ty BlobContentType) IsImage() bool { return ty == BlobContentTypeImage }
122
-
func (ty BlobContentType) IsSvg() bool { return ty == BlobContentTypeSvg }
123
-
func (ty BlobContentType) IsVideo() bool { return ty == BlobContentTypeVideo }
124
-
func (ty BlobContentType) IsSubmodule() bool { return ty == BlobContentTypeSubmodule }
125
-
126
-
type BlobView struct {
127
-
HasTextView bool // can show as code/text
128
-
HasRenderedView bool // can show rendered (markup/image/video/submodule)
129
-
HasRawView bool // can download raw (everything except submodule)
130
-
131
-
// current display mode
132
-
ShowingRendered bool // currently in rendered mode
133
-
ShowingText bool // currently in text/code mode
134
-
135
-
// content type flags
136
-
ContentType BlobContentType
137
-
138
-
// Content data
139
-
Contents string
140
-
ContentSrc string // URL for media files
141
-
Lines int
142
-
SizeHint uint64
143
-
}
144
-
145
-
// if both views are available, then show a toggle between them
146
-
func (b BlobView) ShowToggle() bool {
147
-
return b.HasTextView && b.HasRenderedView
148
-
}
149
-
150
-
func (b BlobView) IsUnsupported() bool {
151
-
// no view available, only raw
152
-
return !(b.HasRenderedView || b.HasTextView)
153
-
}
-31
appview/models/search.go
-31
appview/models/search.go
···
1
-
package models
2
-
3
-
import "tangled.org/core/appview/pagination"
4
-
5
-
type IssueSearchOptions struct {
6
-
Keyword string
7
-
RepoAt string
8
-
IsOpen bool
9
-
10
-
Page pagination.Page
11
-
}
12
-
13
-
type PullSearchOptions struct {
14
-
Keyword string
15
-
RepoAt string
16
-
State PullState
17
-
18
-
Page pagination.Page
19
-
}
20
-
21
-
// func (so *SearchOptions) ToFilters() []filter {
22
-
// var filters []filter
23
-
// if so.IsOpen != nil {
24
-
// openValue := 0
25
-
// if *so.IsOpen {
26
-
// openValue = 1
27
-
// }
28
-
// filters = append(filters, FilterEq("open", openValue))
29
-
// }
30
-
// return filters
31
-
// }
+39
-36
appview/notifications/notifications.go
+39
-36
appview/notifications/notifications.go
···
1
1
package notifications
2
2
3
3
import (
4
-
"log/slog"
4
+
"fmt"
5
+
"log"
5
6
"net/http"
6
7
"strconv"
7
8
···
14
15
)
15
16
16
17
type Notifications struct {
17
-
db *db.DB
18
-
oauth *oauth.OAuth
19
-
pages *pages.Pages
20
-
logger *slog.Logger
18
+
db *db.DB
19
+
oauth *oauth.OAuth
20
+
pages *pages.Pages
21
21
}
22
22
23
-
func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications {
23
+
func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications {
24
24
return &Notifications{
25
-
db: database,
26
-
oauth: oauthHandler,
27
-
pages: pagesHandler,
28
-
logger: logger,
25
+
db: database,
26
+
oauth: oauthHandler,
27
+
pages: pagesHandler,
29
28
}
30
29
}
31
30
32
31
func (n *Notifications) Router(mw *middleware.Middleware) http.Handler {
33
32
r := chi.NewRouter()
34
33
34
+
r.Use(middleware.AuthMiddleware(n.oauth))
35
+
36
+
r.With(middleware.Paginate).Get("/", n.notificationsPage)
37
+
35
38
r.Get("/count", n.getUnreadCount)
36
-
37
-
r.Group(func(r chi.Router) {
38
-
r.Use(middleware.AuthMiddleware(n.oauth))
39
-
r.With(middleware.Paginate).Get("/", n.notificationsPage)
40
-
r.Post("/{id}/read", n.markRead)
41
-
r.Post("/read-all", n.markAllRead)
42
-
r.Delete("/{id}", n.deleteNotification)
43
-
})
39
+
r.Post("/{id}/read", n.markRead)
40
+
r.Post("/read-all", n.markAllRead)
41
+
r.Delete("/{id}", n.deleteNotification)
44
42
45
43
return r
46
44
}
47
45
48
46
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
49
-
l := n.logger.With("handler", "notificationsPage")
50
-
user := n.oauth.GetUser(r)
47
+
userDid := n.oauth.GetDid(r)
51
48
52
-
page := pagination.FromContext(r.Context())
49
+
page, ok := r.Context().Value("page").(pagination.Page)
50
+
if !ok {
51
+
log.Println("failed to get page")
52
+
page = pagination.FirstPage()
53
+
}
53
54
54
55
total, err := db.CountNotifications(
55
56
n.db,
56
-
db.FilterEq("recipient_did", user.Did),
57
+
db.FilterEq("recipient_did", userDid),
57
58
)
58
59
if err != nil {
59
-
l.Error("failed to get total notifications", "err", err)
60
+
log.Println("failed to get total notifications:", err)
60
61
n.pages.Error500(w)
61
62
return
62
63
}
···
64
65
notifications, err := db.GetNotificationsWithEntities(
65
66
n.db,
66
67
page,
67
-
db.FilterEq("recipient_did", user.Did),
68
+
db.FilterEq("recipient_did", userDid),
68
69
)
69
70
if err != nil {
70
-
l.Error("failed to get notifications", "err", err)
71
+
log.Println("failed to get notifications:", err)
71
72
n.pages.Error500(w)
72
73
return
73
74
}
74
75
75
-
err = db.MarkAllNotificationsRead(n.db, user.Did)
76
+
err = n.db.MarkAllNotificationsRead(r.Context(), userDid)
76
77
if err != nil {
77
-
l.Error("failed to mark notifications as read", "err", err)
78
+
log.Println("failed to mark notifications as read:", err)
78
79
}
79
80
80
81
unreadCount := 0
81
82
82
-
n.pages.Notifications(w, pages.NotificationsParams{
83
+
user := n.oauth.GetUser(r)
84
+
if user == nil {
85
+
http.Error(w, "Failed to get user", http.StatusInternalServerError)
86
+
return
87
+
}
88
+
89
+
fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{
83
90
LoggedInUser: user,
84
91
Notifications: notifications,
85
92
UnreadCount: unreadCount,
86
93
Page: page,
87
94
Total: total,
88
-
})
95
+
}))
89
96
}
90
97
91
98
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
92
99
user := n.oauth.GetUser(r)
93
-
if user == nil {
94
-
return
95
-
}
96
-
97
100
count, err := db.CountNotifications(
98
101
n.db,
99
102
db.FilterEq("recipient_did", user.Did),
···
124
127
return
125
128
}
126
129
127
-
err = db.MarkNotificationRead(n.db, notificationID, userDid)
130
+
err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid)
128
131
if err != nil {
129
132
http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError)
130
133
return
···
136
139
func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) {
137
140
userDid := n.oauth.GetDid(r)
138
141
139
-
err := db.MarkAllNotificationsRead(n.db, userDid)
142
+
err := n.db.MarkAllNotificationsRead(r.Context(), userDid)
140
143
if err != nil {
141
144
http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError)
142
145
return
···
155
158
return
156
159
}
157
160
158
-
err = db.DeleteNotification(n.db, notificationID, userDid)
161
+
err = n.db.DeleteNotification(r.Context(), notificationID, userDid)
159
162
if err != nil {
160
163
http.Error(w, "Failed to delete notification", http.StatusInternalServerError)
161
164
return
+260
-320
appview/notify/db/db.go
+260
-320
appview/notify/db/db.go
···
3
3
import (
4
4
"context"
5
5
"log"
6
-
"maps"
7
-
"slices"
8
6
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
7
"tangled.org/core/appview/db"
11
8
"tangled.org/core/appview/models"
12
9
"tangled.org/core/appview/notify"
13
10
"tangled.org/core/idresolver"
14
-
)
15
-
16
-
const (
17
-
maxMentions = 5
18
11
)
19
12
20
13
type databaseNotifier struct {
···
43
36
return
44
37
}
45
38
46
-
actorDid := syntax.DID(star.StarredByDid)
47
-
recipients := []syntax.DID{syntax.DID(repo.Did)}
48
-
eventType := models.NotificationTypeRepoStarred
49
-
entityType := "repo"
50
-
entityId := star.RepoAt.String()
51
-
repoId := &repo.Id
52
-
var issueId *int64
53
-
var pullId *int64
39
+
// don't notify yourself
40
+
if repo.Did == star.StarredByDid {
41
+
return
42
+
}
54
43
55
-
n.notifyEvent(
56
-
actorDid,
57
-
recipients,
58
-
eventType,
59
-
entityType,
60
-
entityId,
61
-
repoId,
62
-
issueId,
63
-
pullId,
64
-
)
44
+
// check if user wants these notifications
45
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
46
+
if err != nil {
47
+
log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err)
48
+
return
49
+
}
50
+
if !prefs.RepoStarred {
51
+
return
52
+
}
53
+
54
+
notification := &models.Notification{
55
+
RecipientDid: repo.Did,
56
+
ActorDid: star.StarredByDid,
57
+
Type: models.NotificationTypeRepoStarred,
58
+
EntityType: "repo",
59
+
EntityId: string(star.RepoAt),
60
+
RepoId: &repo.Id,
61
+
}
62
+
err = n.db.CreateNotification(ctx, notification)
63
+
if err != nil {
64
+
log.Printf("NewStar: failed to create notification: %v", err)
65
+
return
66
+
}
65
67
}
66
68
67
69
func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {
68
70
// no-op
69
71
}
70
72
71
-
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
73
+
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
74
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
75
+
if err != nil {
76
+
log.Printf("NewIssue: failed to get repos: %v", err)
77
+
return
78
+
}
72
79
73
-
// build the recipients list
74
-
// - owner of the repo
75
-
// - collaborators in the repo
76
-
var recipients []syntax.DID
77
-
recipients = append(recipients, syntax.DID(issue.Repo.Did))
78
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
80
+
if repo.Did == issue.Did {
81
+
return
82
+
}
83
+
84
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
79
85
if err != nil {
80
-
log.Printf("failed to fetch collaborators: %v", err)
86
+
log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err)
81
87
return
82
88
}
83
-
for _, c := range collaborators {
84
-
recipients = append(recipients, c.SubjectDid)
89
+
if !prefs.IssueCreated {
90
+
return
85
91
}
86
92
87
-
actorDid := syntax.DID(issue.Did)
88
-
entityType := "issue"
89
-
entityId := issue.AtUri().String()
90
-
repoId := &issue.Repo.Id
91
-
issueId := &issue.Id
92
-
var pullId *int64
93
+
notification := &models.Notification{
94
+
RecipientDid: repo.Did,
95
+
ActorDid: issue.Did,
96
+
Type: models.NotificationTypeIssueCreated,
97
+
EntityType: "issue",
98
+
EntityId: string(issue.AtUri()),
99
+
RepoId: &repo.Id,
100
+
IssueId: &issue.Id,
101
+
}
93
102
94
-
n.notifyEvent(
95
-
actorDid,
96
-
recipients,
97
-
models.NotificationTypeIssueCreated,
98
-
entityType,
99
-
entityId,
100
-
repoId,
101
-
issueId,
102
-
pullId,
103
-
)
104
-
n.notifyEvent(
105
-
actorDid,
106
-
mentions,
107
-
models.NotificationTypeUserMentioned,
108
-
entityType,
109
-
entityId,
110
-
repoId,
111
-
issueId,
112
-
pullId,
113
-
)
103
+
err = n.db.CreateNotification(ctx, notification)
104
+
if err != nil {
105
+
log.Printf("NewIssue: failed to create notification: %v", err)
106
+
return
107
+
}
114
108
}
115
109
116
-
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
110
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
117
111
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
118
112
if err != nil {
119
113
log.Printf("NewIssueComment: failed to get issues: %v", err)
···
125
119
}
126
120
issue := issues[0]
127
121
128
-
var recipients []syntax.DID
129
-
recipients = append(recipients, syntax.DID(issue.Repo.Did))
122
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
123
+
if err != nil {
124
+
log.Printf("NewIssueComment: failed to get repos: %v", err)
125
+
return
126
+
}
130
127
131
-
if comment.IsReply() {
132
-
// if this comment is a reply, then notify everybody in that thread
133
-
parentAtUri := *comment.ReplyTo
134
-
allThreads := issue.CommentList()
128
+
recipients := make(map[string]bool)
135
129
136
-
// find the parent thread, and add all DIDs from here to the recipient list
137
-
for _, t := range allThreads {
138
-
if t.Self.AtUri().String() == parentAtUri {
139
-
recipients = append(recipients, t.Participants()...)
140
-
}
130
+
// notify issue author (if not the commenter)
131
+
if issue.Did != comment.Did {
132
+
prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did)
133
+
if err == nil && prefs.IssueCommented {
134
+
recipients[issue.Did] = true
135
+
} else if err != nil {
136
+
log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err)
141
137
}
142
-
} else {
143
-
// not a reply, notify just the issue author
144
-
recipients = append(recipients, syntax.DID(issue.Did))
145
138
}
146
139
147
-
actorDid := syntax.DID(comment.Did)
148
-
entityType := "issue"
149
-
entityId := issue.AtUri().String()
150
-
repoId := &issue.Repo.Id
151
-
issueId := &issue.Id
152
-
var pullId *int64
140
+
// notify repo owner (if not the commenter and not already added)
141
+
if repo.Did != comment.Did && repo.Did != issue.Did {
142
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
143
+
if err == nil && prefs.IssueCommented {
144
+
recipients[repo.Did] = true
145
+
} else if err != nil {
146
+
log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
147
+
}
148
+
}
153
149
154
-
n.notifyEvent(
155
-
actorDid,
156
-
recipients,
157
-
models.NotificationTypeIssueCommented,
158
-
entityType,
159
-
entityId,
160
-
repoId,
161
-
issueId,
162
-
pullId,
163
-
)
164
-
n.notifyEvent(
165
-
actorDid,
166
-
mentions,
167
-
models.NotificationTypeUserMentioned,
168
-
entityType,
169
-
entityId,
170
-
repoId,
171
-
issueId,
172
-
pullId,
173
-
)
174
-
}
150
+
// create notifications for all recipients
151
+
for recipientDid := range recipients {
152
+
notification := &models.Notification{
153
+
RecipientDid: recipientDid,
154
+
ActorDid: comment.Did,
155
+
Type: models.NotificationTypeIssueCommented,
156
+
EntityType: "issue",
157
+
EntityId: string(issue.AtUri()),
158
+
RepoId: &repo.Id,
159
+
IssueId: &issue.Id,
160
+
}
175
161
176
-
func (n *databaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {
177
-
// no-op for now
162
+
err = n.db.CreateNotification(ctx, notification)
163
+
if err != nil {
164
+
log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err)
165
+
}
166
+
}
178
167
}
179
168
180
169
func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
181
-
actorDid := syntax.DID(follow.UserDid)
182
-
recipients := []syntax.DID{syntax.DID(follow.SubjectDid)}
183
-
eventType := models.NotificationTypeFollowed
184
-
entityType := "follow"
185
-
entityId := follow.UserDid
186
-
var repoId, issueId, pullId *int64
170
+
prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid)
171
+
if err != nil {
172
+
log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err)
173
+
return
174
+
}
175
+
if !prefs.Followed {
176
+
return
177
+
}
178
+
179
+
notification := &models.Notification{
180
+
RecipientDid: follow.SubjectDid,
181
+
ActorDid: follow.UserDid,
182
+
Type: models.NotificationTypeFollowed,
183
+
EntityType: "follow",
184
+
EntityId: follow.UserDid,
185
+
}
187
186
188
-
n.notifyEvent(
189
-
actorDid,
190
-
recipients,
191
-
eventType,
192
-
entityType,
193
-
entityId,
194
-
repoId,
195
-
issueId,
196
-
pullId,
197
-
)
187
+
err = n.db.CreateNotification(ctx, notification)
188
+
if err != nil {
189
+
log.Printf("NewFollow: failed to create notification: %v", err)
190
+
return
191
+
}
198
192
}
199
193
200
194
func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
···
208
202
return
209
203
}
210
204
211
-
// build the recipients list
212
-
// - owner of the repo
213
-
// - collaborators in the repo
214
-
var recipients []syntax.DID
215
-
recipients = append(recipients, syntax.DID(repo.Did))
216
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
205
+
if repo.Did == pull.OwnerDid {
206
+
return
207
+
}
208
+
209
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
217
210
if err != nil {
218
-
log.Printf("failed to fetch collaborators: %v", err)
211
+
log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err)
219
212
return
220
213
}
221
-
for _, c := range collaborators {
222
-
recipients = append(recipients, c.SubjectDid)
214
+
if !prefs.PullCreated {
215
+
return
223
216
}
224
217
225
-
actorDid := syntax.DID(pull.OwnerDid)
226
-
eventType := models.NotificationTypePullCreated
227
-
entityType := "pull"
228
-
entityId := pull.AtUri().String()
229
-
repoId := &repo.Id
230
-
var issueId *int64
231
-
p := int64(pull.ID)
232
-
pullId := &p
218
+
notification := &models.Notification{
219
+
RecipientDid: repo.Did,
220
+
ActorDid: pull.OwnerDid,
221
+
Type: models.NotificationTypePullCreated,
222
+
EntityType: "pull",
223
+
EntityId: string(pull.RepoAt),
224
+
RepoId: &repo.Id,
225
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
226
+
}
233
227
234
-
n.notifyEvent(
235
-
actorDid,
236
-
recipients,
237
-
eventType,
238
-
entityType,
239
-
entityId,
240
-
repoId,
241
-
issueId,
242
-
pullId,
243
-
)
228
+
err = n.db.CreateNotification(ctx, notification)
229
+
if err != nil {
230
+
log.Printf("NewPull: failed to create notification: %v", err)
231
+
return
232
+
}
244
233
}
245
234
246
-
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
247
-
pull, err := db.GetPull(n.db,
248
-
syntax.ATURI(comment.RepoAt),
249
-
comment.PullId,
250
-
)
235
+
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
236
+
pulls, err := db.GetPulls(n.db,
237
+
db.FilterEq("repo_at", comment.RepoAt),
238
+
db.FilterEq("pull_id", comment.PullId))
251
239
if err != nil {
252
240
log.Printf("NewPullComment: failed to get pulls: %v", err)
253
241
return
254
242
}
243
+
if len(pulls) == 0 {
244
+
log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId)
245
+
return
246
+
}
247
+
pull := pulls[0]
255
248
256
249
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
257
250
if err != nil {
···
259
252
return
260
253
}
261
254
262
-
// build up the recipients list:
263
-
// - repo owner
264
-
// - all pull participants
265
-
var recipients []syntax.DID
266
-
recipients = append(recipients, syntax.DID(repo.Did))
267
-
for _, p := range pull.Participants() {
268
-
recipients = append(recipients, syntax.DID(p))
255
+
recipients := make(map[string]bool)
256
+
257
+
// notify pull request author (if not the commenter)
258
+
if pull.OwnerDid != comment.OwnerDid {
259
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
260
+
if err == nil && prefs.PullCommented {
261
+
recipients[pull.OwnerDid] = true
262
+
} else if err != nil {
263
+
log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err)
264
+
}
269
265
}
270
266
271
-
actorDid := syntax.DID(comment.OwnerDid)
272
-
eventType := models.NotificationTypePullCommented
273
-
entityType := "pull"
274
-
entityId := pull.AtUri().String()
275
-
repoId := &repo.Id
276
-
var issueId *int64
277
-
p := int64(pull.ID)
278
-
pullId := &p
267
+
// notify repo owner (if not the commenter and not already added)
268
+
if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid {
269
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
270
+
if err == nil && prefs.PullCommented {
271
+
recipients[repo.Did] = true
272
+
} else if err != nil {
273
+
log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
274
+
}
275
+
}
276
+
277
+
for recipientDid := range recipients {
278
+
notification := &models.Notification{
279
+
RecipientDid: recipientDid,
280
+
ActorDid: comment.OwnerDid,
281
+
Type: models.NotificationTypePullCommented,
282
+
EntityType: "pull",
283
+
EntityId: comment.RepoAt,
284
+
RepoId: &repo.Id,
285
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
286
+
}
279
287
280
-
n.notifyEvent(
281
-
actorDid,
282
-
recipients,
283
-
eventType,
284
-
entityType,
285
-
entityId,
286
-
repoId,
287
-
issueId,
288
-
pullId,
289
-
)
290
-
n.notifyEvent(
291
-
actorDid,
292
-
mentions,
293
-
models.NotificationTypeUserMentioned,
294
-
entityType,
295
-
entityId,
296
-
repoId,
297
-
issueId,
298
-
pullId,
299
-
)
288
+
err = n.db.CreateNotification(ctx, notification)
289
+
if err != nil {
290
+
log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err)
291
+
}
292
+
}
300
293
}
301
294
302
295
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
···
315
308
// no-op
316
309
}
317
310
318
-
func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
319
-
// build up the recipients list:
320
-
// - repo owner
321
-
// - repo collaborators
322
-
// - all issue participants
323
-
var recipients []syntax.DID
324
-
recipients = append(recipients, syntax.DID(issue.Repo.Did))
325
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
311
+
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
312
+
// Get repo details
313
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
326
314
if err != nil {
327
-
log.Printf("failed to fetch collaborators: %v", err)
315
+
log.Printf("NewIssueClosed: failed to get repos: %v", err)
328
316
return
329
317
}
330
-
for _, c := range collaborators {
331
-
recipients = append(recipients, c.SubjectDid)
332
-
}
333
-
for _, p := range issue.Participants() {
334
-
recipients = append(recipients, syntax.DID(p))
318
+
319
+
// Don't notify yourself
320
+
if repo.Did == issue.Did {
321
+
return
335
322
}
336
323
337
-
entityType := "pull"
338
-
entityId := issue.AtUri().String()
339
-
repoId := &issue.Repo.Id
340
-
issueId := &issue.Id
341
-
var pullId *int64
342
-
var eventType models.NotificationType
324
+
// Check if user wants these notifications
325
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
326
+
if err != nil {
327
+
log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err)
328
+
return
329
+
}
330
+
if !prefs.IssueClosed {
331
+
return
332
+
}
343
333
344
-
if issue.Open {
345
-
eventType = models.NotificationTypeIssueReopen
346
-
} else {
347
-
eventType = models.NotificationTypeIssueClosed
334
+
notification := &models.Notification{
335
+
RecipientDid: repo.Did,
336
+
ActorDid: issue.Did,
337
+
Type: models.NotificationTypeIssueClosed,
338
+
EntityType: "issue",
339
+
EntityId: string(issue.AtUri()),
340
+
RepoId: &repo.Id,
341
+
IssueId: &issue.Id,
348
342
}
349
343
350
-
n.notifyEvent(
351
-
actor,
352
-
recipients,
353
-
eventType,
354
-
entityType,
355
-
entityId,
356
-
repoId,
357
-
issueId,
358
-
pullId,
359
-
)
344
+
err = n.db.CreateNotification(ctx, notification)
345
+
if err != nil {
346
+
log.Printf("NewIssueClosed: failed to create notification: %v", err)
347
+
return
348
+
}
360
349
}
361
350
362
-
func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
351
+
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
363
352
// Get repo details
364
353
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
365
354
if err != nil {
366
-
log.Printf("NewPullState: failed to get repos: %v", err)
355
+
log.Printf("NewPullMerged: failed to get repos: %v", err)
367
356
return
368
357
}
369
358
370
-
// build up the recipients list:
371
-
// - repo owner
372
-
// - all pull participants
373
-
var recipients []syntax.DID
374
-
recipients = append(recipients, syntax.DID(repo.Did))
375
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
359
+
// Don't notify yourself
360
+
if repo.Did == pull.OwnerDid {
361
+
return
362
+
}
363
+
364
+
// Check if user wants these notifications
365
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
376
366
if err != nil {
377
-
log.Printf("failed to fetch collaborators: %v", err)
367
+
log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
378
368
return
379
369
}
380
-
for _, c := range collaborators {
381
-
recipients = append(recipients, c.SubjectDid)
370
+
if !prefs.PullMerged {
371
+
return
382
372
}
383
-
for _, p := range pull.Participants() {
384
-
recipients = append(recipients, syntax.DID(p))
373
+
374
+
notification := &models.Notification{
375
+
RecipientDid: pull.OwnerDid,
376
+
ActorDid: repo.Did,
377
+
Type: models.NotificationTypePullMerged,
378
+
EntityType: "pull",
379
+
EntityId: string(pull.RepoAt),
380
+
RepoId: &repo.Id,
381
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
385
382
}
386
383
387
-
entityType := "pull"
388
-
entityId := pull.AtUri().String()
389
-
repoId := &repo.Id
390
-
var issueId *int64
391
-
var eventType models.NotificationType
392
-
switch pull.State {
393
-
case models.PullClosed:
394
-
eventType = models.NotificationTypePullClosed
395
-
case models.PullOpen:
396
-
eventType = models.NotificationTypePullReopen
397
-
case models.PullMerged:
398
-
eventType = models.NotificationTypePullMerged
399
-
default:
400
-
log.Println("NewPullState: unexpected new PR state:", pull.State)
384
+
err = n.db.CreateNotification(ctx, notification)
385
+
if err != nil {
386
+
log.Printf("NewPullMerged: failed to create notification: %v", err)
401
387
return
402
388
}
403
-
p := int64(pull.ID)
404
-
pullId := &p
405
-
406
-
n.notifyEvent(
407
-
actor,
408
-
recipients,
409
-
eventType,
410
-
entityType,
411
-
entityId,
412
-
repoId,
413
-
issueId,
414
-
pullId,
415
-
)
416
389
}
417
390
418
-
func (n *databaseNotifier) notifyEvent(
419
-
actorDid syntax.DID,
420
-
recipients []syntax.DID,
421
-
eventType models.NotificationType,
422
-
entityType string,
423
-
entityId string,
424
-
repoId *int64,
425
-
issueId *int64,
426
-
pullId *int64,
427
-
) {
428
-
if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions {
429
-
recipients = recipients[:maxMentions]
430
-
}
431
-
recipientSet := make(map[syntax.DID]struct{})
432
-
for _, did := range recipients {
433
-
// everybody except actor themselves
434
-
if did != actorDid {
435
-
recipientSet[did] = struct{}{}
436
-
}
391
+
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
392
+
// Get repo details
393
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
394
+
if err != nil {
395
+
log.Printf("NewPullClosed: failed to get repos: %v", err)
396
+
return
437
397
}
438
398
439
-
prefMap, err := db.GetNotificationPreferences(
440
-
n.db,
441
-
db.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))),
442
-
)
443
-
if err != nil {
444
-
// failed to get prefs for users
399
+
// Don't notify yourself
400
+
if repo.Did == pull.OwnerDid {
445
401
return
446
402
}
447
403
448
-
// create a transaction for bulk notification storage
449
-
tx, err := n.db.Begin()
404
+
// Check if user wants these notifications - reuse pull_merged preference for now
405
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
450
406
if err != nil {
451
-
// failed to start tx
407
+
log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
452
408
return
453
409
}
454
-
defer tx.Rollback()
455
-
456
-
// filter based on preferences
457
-
for recipientDid := range recipientSet {
458
-
prefs, ok := prefMap[recipientDid]
459
-
if !ok {
460
-
prefs = models.DefaultNotificationPreferences(recipientDid)
461
-
}
462
-
463
-
// skip users who don’t want this type
464
-
if !prefs.ShouldNotify(eventType) {
465
-
continue
466
-
}
467
-
468
-
// create notification
469
-
notif := &models.Notification{
470
-
RecipientDid: recipientDid.String(),
471
-
ActorDid: actorDid.String(),
472
-
Type: eventType,
473
-
EntityType: entityType,
474
-
EntityId: entityId,
475
-
RepoId: repoId,
476
-
IssueId: issueId,
477
-
PullId: pullId,
478
-
}
410
+
if !prefs.PullMerged {
411
+
return
412
+
}
479
413
480
-
if err := db.CreateNotification(tx, notif); err != nil {
481
-
log.Printf("notifyEvent: failed to create notification for %s: %v", recipientDid, err)
482
-
}
414
+
notification := &models.Notification{
415
+
RecipientDid: pull.OwnerDid,
416
+
ActorDid: repo.Did,
417
+
Type: models.NotificationTypePullClosed,
418
+
EntityType: "pull",
419
+
EntityId: string(pull.RepoAt),
420
+
RepoId: &repo.Id,
421
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
483
422
}
484
423
485
-
if err := tx.Commit(); err != nil {
486
-
// failed to commit
424
+
err = n.db.CreateNotification(ctx, notification)
425
+
if err != nil {
426
+
log.Printf("NewPullClosed: failed to create notification: %v", err)
487
427
return
488
428
}
489
429
}
+59
-57
appview/notify/merged_notifier.go
+59
-57
appview/notify/merged_notifier.go
···
2
2
3
3
import (
4
4
"context"
5
-
"log/slog"
6
-
"reflect"
7
-
"sync"
8
5
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
6
"tangled.org/core/appview/models"
11
-
"tangled.org/core/log"
12
7
)
13
8
14
9
type mergedNotifier struct {
15
10
notifiers []Notifier
16
-
logger *slog.Logger
17
11
}
18
12
19
-
func NewMergedNotifier(notifiers []Notifier, logger *slog.Logger) Notifier {
20
-
return &mergedNotifier{notifiers, logger}
13
+
func NewMergedNotifier(notifiers ...Notifier) Notifier {
14
+
return &mergedNotifier{notifiers}
21
15
}
22
16
23
17
var _ Notifier = &mergedNotifier{}
24
18
25
-
// fanout calls the same method on all notifiers concurrently
26
-
func (m *mergedNotifier) fanout(method string, ctx context.Context, args ...any) {
27
-
ctx = log.IntoContext(ctx, m.logger.With("method", method))
28
-
var wg sync.WaitGroup
29
-
for _, n := range m.notifiers {
30
-
wg.Add(1)
31
-
go func(notifier Notifier) {
32
-
defer wg.Done()
33
-
v := reflect.ValueOf(notifier).MethodByName(method)
34
-
in := make([]reflect.Value, len(args)+1)
35
-
in[0] = reflect.ValueOf(ctx)
36
-
for i, arg := range args {
37
-
in[i+1] = reflect.ValueOf(arg)
38
-
}
39
-
v.Call(in)
40
-
}(n)
41
-
}
42
-
wg.Wait()
43
-
}
44
-
45
19
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
46
-
m.fanout("NewRepo", ctx, repo)
20
+
for _, notifier := range m.notifiers {
21
+
notifier.NewRepo(ctx, repo)
22
+
}
47
23
}
48
24
49
25
func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) {
50
-
m.fanout("NewStar", ctx, star)
26
+
for _, notifier := range m.notifiers {
27
+
notifier.NewStar(ctx, star)
28
+
}
51
29
}
52
-
53
30
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) {
54
-
m.fanout("DeleteStar", ctx, star)
31
+
for _, notifier := range m.notifiers {
32
+
notifier.DeleteStar(ctx, star)
33
+
}
55
34
}
56
35
57
-
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
58
-
m.fanout("NewIssue", ctx, issue, mentions)
59
-
}
60
-
61
-
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
62
-
m.fanout("NewIssueComment", ctx, comment, mentions)
36
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
37
+
for _, notifier := range m.notifiers {
38
+
notifier.NewIssue(ctx, issue)
39
+
}
63
40
}
64
-
65
-
func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
66
-
m.fanout("NewIssueState", ctx, actor, issue)
41
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
42
+
for _, notifier := range m.notifiers {
43
+
notifier.NewIssueComment(ctx, comment)
44
+
}
67
45
}
68
46
69
-
func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {
70
-
m.fanout("DeleteIssue", ctx, issue)
47
+
func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
48
+
for _, notifier := range m.notifiers {
49
+
notifier.NewIssueClosed(ctx, issue)
50
+
}
71
51
}
72
52
73
53
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
74
-
m.fanout("NewFollow", ctx, follow)
54
+
for _, notifier := range m.notifiers {
55
+
notifier.NewFollow(ctx, follow)
56
+
}
75
57
}
76
-
77
58
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
78
-
m.fanout("DeleteFollow", ctx, follow)
59
+
for _, notifier := range m.notifiers {
60
+
notifier.DeleteFollow(ctx, follow)
61
+
}
79
62
}
80
63
81
64
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) {
82
-
m.fanout("NewPull", ctx, pull)
65
+
for _, notifier := range m.notifiers {
66
+
notifier.NewPull(ctx, pull)
67
+
}
68
+
}
69
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
70
+
for _, notifier := range m.notifiers {
71
+
notifier.NewPullComment(ctx, comment)
72
+
}
83
73
}
84
74
85
-
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
86
-
m.fanout("NewPullComment", ctx, comment, mentions)
75
+
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
76
+
for _, notifier := range m.notifiers {
77
+
notifier.NewPullMerged(ctx, pull)
78
+
}
87
79
}
88
80
89
-
func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
90
-
m.fanout("NewPullState", ctx, actor, pull)
81
+
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
82
+
for _, notifier := range m.notifiers {
83
+
notifier.NewPullClosed(ctx, pull)
84
+
}
91
85
}
92
86
93
87
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
94
-
m.fanout("UpdateProfile", ctx, profile)
88
+
for _, notifier := range m.notifiers {
89
+
notifier.UpdateProfile(ctx, profile)
90
+
}
95
91
}
96
92
97
-
func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) {
98
-
m.fanout("NewString", ctx, s)
93
+
func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) {
94
+
for _, notifier := range m.notifiers {
95
+
notifier.NewString(ctx, string)
96
+
}
99
97
}
100
98
101
-
func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) {
102
-
m.fanout("EditString", ctx, s)
99
+
func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) {
100
+
for _, notifier := range m.notifiers {
101
+
notifier.EditString(ctx, string)
102
+
}
103
103
}
104
104
105
105
func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) {
106
-
m.fanout("DeleteString", ctx, did, rkey)
106
+
for _, notifier := range m.notifiers {
107
+
notifier.DeleteString(ctx, did, rkey)
108
+
}
107
109
}
+13
-16
appview/notify/notifier.go
+13
-16
appview/notify/notifier.go
···
3
3
import (
4
4
"context"
5
5
6
-
"github.com/bluesky-social/indigo/atproto/syntax"
7
6
"tangled.org/core/appview/models"
8
7
)
9
8
···
13
12
NewStar(ctx context.Context, star *models.Star)
14
13
DeleteStar(ctx context.Context, star *models.Star)
15
14
16
-
NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID)
17
-
NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID)
18
-
NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue)
19
-
DeleteIssue(ctx context.Context, issue *models.Issue)
15
+
NewIssue(ctx context.Context, issue *models.Issue)
16
+
NewIssueComment(ctx context.Context, comment *models.IssueComment)
17
+
NewIssueClosed(ctx context.Context, issue *models.Issue)
20
18
21
19
NewFollow(ctx context.Context, follow *models.Follow)
22
20
DeleteFollow(ctx context.Context, follow *models.Follow)
23
21
24
22
NewPull(ctx context.Context, pull *models.Pull)
25
-
NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID)
26
-
NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull)
23
+
NewPullComment(ctx context.Context, comment *models.PullComment)
24
+
NewPullMerged(ctx context.Context, pull *models.Pull)
25
+
NewPullClosed(ctx context.Context, pull *models.Pull)
27
26
28
27
UpdateProfile(ctx context.Context, profile *models.Profile)
29
28
···
42
41
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
43
42
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
44
43
45
-
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {}
46
-
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
47
-
}
48
-
func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {}
49
-
func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {}
44
+
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {}
45
+
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {}
46
+
func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {}
50
47
51
48
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
52
49
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
53
50
54
-
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
55
-
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) {
56
-
}
57
-
func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {}
51
+
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
52
+
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
53
+
func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {}
54
+
func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {}
58
55
59
56
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
60
57
+9
-33
appview/notify/posthog/notifier.go
+9
-33
appview/notify/posthog/notifier.go
···
4
4
"context"
5
5
"log"
6
6
7
-
"github.com/bluesky-social/indigo/atproto/syntax"
8
7
"github.com/posthog/posthog-go"
9
8
"tangled.org/core/appview/models"
10
9
"tangled.org/core/appview/notify"
···
57
56
}
58
57
}
59
58
60
-
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
59
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
61
60
err := n.client.Enqueue(posthog.Capture{
62
61
DistinctId: issue.Did,
63
62
Event: "new_issue",
64
63
Properties: posthog.Properties{
65
64
"repo_at": issue.RepoAt.String(),
66
65
"issue_id": issue.IssueId,
67
-
"mentions": mentions,
68
66
},
69
67
})
70
68
if err != nil {
···
86
84
}
87
85
}
88
86
89
-
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
87
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
90
88
err := n.client.Enqueue(posthog.Capture{
91
89
DistinctId: comment.OwnerDid,
92
90
Event: "new_pull_comment",
93
91
Properties: posthog.Properties{
94
-
"repo_at": comment.RepoAt,
95
-
"pull_id": comment.PullId,
96
-
"mentions": mentions,
92
+
"repo_at": comment.RepoAt,
93
+
"pull_id": comment.PullId,
97
94
},
98
95
})
99
96
if err != nil {
···
180
177
}
181
178
}
182
179
183
-
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
180
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
184
181
err := n.client.Enqueue(posthog.Capture{
185
182
DistinctId: comment.Did,
186
183
Event: "new_issue_comment",
187
184
Properties: posthog.Properties{
188
185
"issue_at": comment.IssueAt,
189
-
"mentions": mentions,
190
186
},
191
187
})
192
188
if err != nil {
···
194
190
}
195
191
}
196
192
197
-
func (n *posthogNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
198
-
var event string
199
-
if issue.Open {
200
-
event = "issue_reopen"
201
-
} else {
202
-
event = "issue_closed"
203
-
}
193
+
func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
204
194
err := n.client.Enqueue(posthog.Capture{
205
195
DistinctId: issue.Did,
206
-
Event: event,
196
+
Event: "issue_closed",
207
197
Properties: posthog.Properties{
208
198
"repo_at": issue.RepoAt.String(),
209
-
"actor": actor,
210
199
"issue_id": issue.IssueId,
211
200
},
212
201
})
···
215
204
}
216
205
}
217
206
218
-
func (n *posthogNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
219
-
var event string
220
-
switch pull.State {
221
-
case models.PullClosed:
222
-
event = "pull_closed"
223
-
case models.PullOpen:
224
-
event = "pull_reopen"
225
-
case models.PullMerged:
226
-
event = "pull_merged"
227
-
default:
228
-
log.Println("posthog: unexpected new PR state:", pull.State)
229
-
return
230
-
}
207
+
func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
231
208
err := n.client.Enqueue(posthog.Capture{
232
209
DistinctId: pull.OwnerDid,
233
-
Event: event,
210
+
Event: "pull_merged",
234
211
Properties: posthog.Properties{
235
212
"repo_at": pull.RepoAt,
236
213
"pull_id": pull.PullId,
237
-
"actor": actor,
238
214
},
239
215
})
240
216
if err != nil {
+24
appview/oauth/client/oauth_client.go
+24
appview/oauth/client/oauth_client.go
···
1
+
package client
2
+
3
+
import (
4
+
oauth "tangled.sh/icyphox.sh/atproto-oauth"
5
+
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
6
+
)
7
+
8
+
type OAuthClient struct {
9
+
*oauth.Client
10
+
}
11
+
12
+
func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) {
13
+
k, err := helpers.ParseJWKFromBytes([]byte(clientJwk))
14
+
if err != nil {
15
+
return nil, err
16
+
}
17
+
18
+
cli, err := oauth.NewClient(oauth.ClientArgs{
19
+
ClientId: clientId,
20
+
ClientJwk: k,
21
+
RedirectUri: redirectUri,
22
+
})
23
+
return &OAuthClient{cli}, err
24
+
}
+1
-2
appview/oauth/consts.go
+1
-2
appview/oauth/consts.go
+538
appview/oauth/handler/handler.go
+538
appview/oauth/handler/handler.go
···
1
+
package oauth
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"encoding/json"
7
+
"fmt"
8
+
"log"
9
+
"net/http"
10
+
"net/url"
11
+
"slices"
12
+
"strings"
13
+
"time"
14
+
15
+
"github.com/go-chi/chi/v5"
16
+
"github.com/gorilla/sessions"
17
+
"github.com/lestrrat-go/jwx/v2/jwk"
18
+
"github.com/posthog/posthog-go"
19
+
tangled "tangled.org/core/api/tangled"
20
+
sessioncache "tangled.org/core/appview/cache/session"
21
+
"tangled.org/core/appview/config"
22
+
"tangled.org/core/appview/db"
23
+
"tangled.org/core/appview/middleware"
24
+
"tangled.org/core/appview/oauth"
25
+
"tangled.org/core/appview/oauth/client"
26
+
"tangled.org/core/appview/pages"
27
+
"tangled.org/core/consts"
28
+
"tangled.org/core/idresolver"
29
+
"tangled.org/core/rbac"
30
+
"tangled.org/core/tid"
31
+
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
32
+
)
33
+
34
+
const (
35
+
oauthScope = "atproto transition:generic"
36
+
)
37
+
38
+
type OAuthHandler struct {
39
+
config *config.Config
40
+
pages *pages.Pages
41
+
idResolver *idresolver.Resolver
42
+
sess *sessioncache.SessionStore
43
+
db *db.DB
44
+
store *sessions.CookieStore
45
+
oauth *oauth.OAuth
46
+
enforcer *rbac.Enforcer
47
+
posthog posthog.Client
48
+
}
49
+
50
+
func New(
51
+
config *config.Config,
52
+
pages *pages.Pages,
53
+
idResolver *idresolver.Resolver,
54
+
db *db.DB,
55
+
sess *sessioncache.SessionStore,
56
+
store *sessions.CookieStore,
57
+
oauth *oauth.OAuth,
58
+
enforcer *rbac.Enforcer,
59
+
posthog posthog.Client,
60
+
) *OAuthHandler {
61
+
return &OAuthHandler{
62
+
config: config,
63
+
pages: pages,
64
+
idResolver: idResolver,
65
+
db: db,
66
+
sess: sess,
67
+
store: store,
68
+
oauth: oauth,
69
+
enforcer: enforcer,
70
+
posthog: posthog,
71
+
}
72
+
}
73
+
74
+
func (o *OAuthHandler) Router() http.Handler {
75
+
r := chi.NewRouter()
76
+
77
+
r.Get("/login", o.login)
78
+
r.Post("/login", o.login)
79
+
80
+
r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout)
81
+
82
+
r.Get("/oauth/client-metadata.json", o.clientMetadata)
83
+
r.Get("/oauth/jwks.json", o.jwks)
84
+
r.Get("/oauth/callback", o.callback)
85
+
return r
86
+
}
87
+
88
+
func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) {
89
+
w.Header().Set("Content-Type", "application/json")
90
+
w.WriteHeader(http.StatusOK)
91
+
json.NewEncoder(w).Encode(o.oauth.ClientMetadata())
92
+
}
93
+
94
+
func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) {
95
+
jwks := o.config.OAuth.Jwks
96
+
pubKey, err := pubKeyFromJwk(jwks)
97
+
if err != nil {
98
+
log.Printf("error parsing public key: %v", err)
99
+
http.Error(w, err.Error(), http.StatusInternalServerError)
100
+
return
101
+
}
102
+
103
+
response := helpers.CreateJwksResponseObject(pubKey)
104
+
105
+
w.Header().Set("Content-Type", "application/json")
106
+
w.WriteHeader(http.StatusOK)
107
+
json.NewEncoder(w).Encode(response)
108
+
}
109
+
110
+
func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) {
111
+
switch r.Method {
112
+
case http.MethodGet:
113
+
returnURL := r.URL.Query().Get("return_url")
114
+
o.pages.Login(w, pages.LoginParams{
115
+
ReturnUrl: returnURL,
116
+
})
117
+
case http.MethodPost:
118
+
handle := r.FormValue("handle")
119
+
120
+
// when users copy their handle from bsky.app, it tends to have these characters around it:
121
+
//
122
+
// @nelind.dk:
123
+
// \u202a ensures that the handle is always rendered left to right and
124
+
// \u202c reverts that so the rest of the page renders however it should
125
+
handle = strings.TrimPrefix(handle, "\u202a")
126
+
handle = strings.TrimSuffix(handle, "\u202c")
127
+
128
+
// `@` is harmless
129
+
handle = strings.TrimPrefix(handle, "@")
130
+
131
+
// basic handle validation
132
+
if !strings.Contains(handle, ".") {
133
+
log.Println("invalid handle format", "raw", handle)
134
+
o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle))
135
+
return
136
+
}
137
+
138
+
resolved, err := o.idResolver.ResolveIdent(r.Context(), handle)
139
+
if err != nil {
140
+
log.Println("failed to resolve handle:", err)
141
+
o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle))
142
+
return
143
+
}
144
+
self := o.oauth.ClientMetadata()
145
+
oauthClient, err := client.NewClient(
146
+
self.ClientID,
147
+
o.config.OAuth.Jwks,
148
+
self.RedirectURIs[0],
149
+
)
150
+
151
+
if err != nil {
152
+
log.Println("failed to create oauth client:", err)
153
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
154
+
return
155
+
}
156
+
157
+
authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint())
158
+
if err != nil {
159
+
log.Println("failed to resolve auth server:", err)
160
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
161
+
return
162
+
}
163
+
164
+
authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer)
165
+
if err != nil {
166
+
log.Println("failed to fetch auth server metadata:", err)
167
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
168
+
return
169
+
}
170
+
171
+
dpopKey, err := helpers.GenerateKey(nil)
172
+
if err != nil {
173
+
log.Println("failed to generate dpop key:", err)
174
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
175
+
return
176
+
}
177
+
178
+
dpopKeyJson, err := json.Marshal(dpopKey)
179
+
if err != nil {
180
+
log.Println("failed to marshal dpop key:", err)
181
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
182
+
return
183
+
}
184
+
185
+
parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey)
186
+
if err != nil {
187
+
log.Println("failed to send par auth request:", err)
188
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
189
+
return
190
+
}
191
+
192
+
err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{
193
+
Did: resolved.DID.String(),
194
+
PdsUrl: resolved.PDSEndpoint(),
195
+
Handle: handle,
196
+
AuthserverIss: authMeta.Issuer,
197
+
PkceVerifier: parResp.PkceVerifier,
198
+
DpopAuthserverNonce: parResp.DpopAuthserverNonce,
199
+
DpopPrivateJwk: string(dpopKeyJson),
200
+
State: parResp.State,
201
+
ReturnUrl: r.FormValue("return_url"),
202
+
})
203
+
if err != nil {
204
+
log.Println("failed to save oauth request:", err)
205
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
206
+
return
207
+
}
208
+
209
+
u, _ := url.Parse(authMeta.AuthorizationEndpoint)
210
+
query := url.Values{}
211
+
query.Add("client_id", self.ClientID)
212
+
query.Add("request_uri", parResp.RequestUri)
213
+
u.RawQuery = query.Encode()
214
+
o.pages.HxRedirect(w, u.String())
215
+
}
216
+
}
217
+
218
+
func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) {
219
+
state := r.FormValue("state")
220
+
221
+
oauthRequest, err := o.sess.GetRequestByState(r.Context(), state)
222
+
if err != nil {
223
+
log.Println("failed to get oauth request:", err)
224
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
225
+
return
226
+
}
227
+
228
+
defer func() {
229
+
err := o.sess.DeleteRequestByState(r.Context(), state)
230
+
if err != nil {
231
+
log.Println("failed to delete oauth request for state:", state, err)
232
+
}
233
+
}()
234
+
235
+
error := r.FormValue("error")
236
+
errorDescription := r.FormValue("error_description")
237
+
if error != "" || errorDescription != "" {
238
+
log.Printf("error: %s, %s", error, errorDescription)
239
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
240
+
return
241
+
}
242
+
243
+
code := r.FormValue("code")
244
+
if code == "" {
245
+
log.Println("missing code for state: ", state)
246
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
247
+
return
248
+
}
249
+
250
+
iss := r.FormValue("iss")
251
+
if iss == "" {
252
+
log.Println("missing iss for state: ", state)
253
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
254
+
return
255
+
}
256
+
257
+
if iss != oauthRequest.AuthserverIss {
258
+
log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state)
259
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
260
+
return
261
+
}
262
+
263
+
self := o.oauth.ClientMetadata()
264
+
265
+
oauthClient, err := client.NewClient(
266
+
self.ClientID,
267
+
o.config.OAuth.Jwks,
268
+
self.RedirectURIs[0],
269
+
)
270
+
271
+
if err != nil {
272
+
log.Println("failed to create oauth client:", err)
273
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
274
+
return
275
+
}
276
+
277
+
jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk))
278
+
if err != nil {
279
+
log.Println("failed to parse jwk:", err)
280
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
281
+
return
282
+
}
283
+
284
+
tokenResp, err := oauthClient.InitialTokenRequest(
285
+
r.Context(),
286
+
code,
287
+
oauthRequest.AuthserverIss,
288
+
oauthRequest.PkceVerifier,
289
+
oauthRequest.DpopAuthserverNonce,
290
+
jwk,
291
+
)
292
+
if err != nil {
293
+
log.Println("failed to get token:", err)
294
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
295
+
return
296
+
}
297
+
298
+
if tokenResp.Scope != oauthScope {
299
+
log.Println("scope doesn't match:", tokenResp.Scope)
300
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
301
+
return
302
+
}
303
+
304
+
err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp)
305
+
if err != nil {
306
+
log.Println("failed to save session:", err)
307
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
308
+
return
309
+
}
310
+
311
+
log.Println("session saved successfully")
312
+
go o.addToDefaultKnot(oauthRequest.Did)
313
+
go o.addToDefaultSpindle(oauthRequest.Did)
314
+
315
+
if !o.config.Core.Dev {
316
+
err = o.posthog.Enqueue(posthog.Capture{
317
+
DistinctId: oauthRequest.Did,
318
+
Event: "signin",
319
+
})
320
+
if err != nil {
321
+
log.Println("failed to enqueue posthog event:", err)
322
+
}
323
+
}
324
+
325
+
returnUrl := oauthRequest.ReturnUrl
326
+
if returnUrl == "" {
327
+
returnUrl = "/"
328
+
}
329
+
330
+
http.Redirect(w, r, returnUrl, http.StatusFound)
331
+
}
332
+
333
+
func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) {
334
+
err := o.oauth.ClearSession(r, w)
335
+
if err != nil {
336
+
log.Println("failed to clear session:", err)
337
+
http.Redirect(w, r, "/", http.StatusFound)
338
+
return
339
+
}
340
+
341
+
log.Println("session cleared successfully")
342
+
o.pages.HxRedirect(w, "/login")
343
+
}
344
+
345
+
func pubKeyFromJwk(jwks string) (jwk.Key, error) {
346
+
k, err := helpers.ParseJWKFromBytes([]byte(jwks))
347
+
if err != nil {
348
+
return nil, err
349
+
}
350
+
pubKey, err := k.PublicKey()
351
+
if err != nil {
352
+
return nil, err
353
+
}
354
+
return pubKey, nil
355
+
}
356
+
357
+
func (o *OAuthHandler) addToDefaultSpindle(did string) {
358
+
// use the tangled.sh app password to get an accessJwt
359
+
// and create an sh.tangled.spindle.member record with that
360
+
spindleMembers, err := db.GetSpindleMembers(
361
+
o.db,
362
+
db.FilterEq("instance", "spindle.tangled.sh"),
363
+
db.FilterEq("subject", did),
364
+
)
365
+
if err != nil {
366
+
log.Printf("failed to get spindle members for did %s: %v", did, err)
367
+
return
368
+
}
369
+
370
+
if len(spindleMembers) != 0 {
371
+
log.Printf("did %s is already a member of the default spindle", did)
372
+
return
373
+
}
374
+
375
+
log.Printf("adding %s to default spindle", did)
376
+
session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid)
377
+
if err != nil {
378
+
log.Printf("failed to create session: %s", err)
379
+
return
380
+
}
381
+
382
+
record := tangled.SpindleMember{
383
+
LexiconTypeID: "sh.tangled.spindle.member",
384
+
Subject: did,
385
+
Instance: consts.DefaultSpindle,
386
+
CreatedAt: time.Now().Format(time.RFC3339),
387
+
}
388
+
389
+
if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
390
+
log.Printf("failed to add member to default spindle: %s", err)
391
+
return
392
+
}
393
+
394
+
log.Printf("successfully added %s to default spindle", did)
395
+
}
396
+
397
+
func (o *OAuthHandler) addToDefaultKnot(did string) {
398
+
// use the tangled.sh app password to get an accessJwt
399
+
// and create an sh.tangled.spindle.member record with that
400
+
401
+
allKnots, err := o.enforcer.GetKnotsForUser(did)
402
+
if err != nil {
403
+
log.Printf("failed to get knot members for did %s: %v", did, err)
404
+
return
405
+
}
406
+
407
+
if slices.Contains(allKnots, consts.DefaultKnot) {
408
+
log.Printf("did %s is already a member of the default knot", did)
409
+
return
410
+
}
411
+
412
+
log.Printf("adding %s to default knot", did)
413
+
session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid)
414
+
if err != nil {
415
+
log.Printf("failed to create session: %s", err)
416
+
return
417
+
}
418
+
419
+
record := tangled.KnotMember{
420
+
LexiconTypeID: "sh.tangled.knot.member",
421
+
Subject: did,
422
+
Domain: consts.DefaultKnot,
423
+
CreatedAt: time.Now().Format(time.RFC3339),
424
+
}
425
+
426
+
if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
427
+
log.Printf("failed to add member to default knot: %s", err)
428
+
return
429
+
}
430
+
431
+
if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil {
432
+
log.Printf("failed to set up enforcer rules: %s", err)
433
+
return
434
+
}
435
+
436
+
log.Printf("successfully added %s to default Knot", did)
437
+
}
438
+
439
+
// create a session using apppasswords
440
+
type session struct {
441
+
AccessJwt string `json:"accessJwt"`
442
+
PdsEndpoint string
443
+
Did string
444
+
}
445
+
446
+
func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) {
447
+
if appPassword == "" {
448
+
return nil, fmt.Errorf("no app password configured, skipping member addition")
449
+
}
450
+
451
+
resolved, err := o.idResolver.ResolveIdent(context.Background(), did)
452
+
if err != nil {
453
+
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
454
+
}
455
+
456
+
pdsEndpoint := resolved.PDSEndpoint()
457
+
if pdsEndpoint == "" {
458
+
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
459
+
}
460
+
461
+
sessionPayload := map[string]string{
462
+
"identifier": did,
463
+
"password": appPassword,
464
+
}
465
+
sessionBytes, err := json.Marshal(sessionPayload)
466
+
if err != nil {
467
+
return nil, fmt.Errorf("failed to marshal session payload: %v", err)
468
+
}
469
+
470
+
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
471
+
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
472
+
if err != nil {
473
+
return nil, fmt.Errorf("failed to create session request: %v", err)
474
+
}
475
+
sessionReq.Header.Set("Content-Type", "application/json")
476
+
477
+
client := &http.Client{Timeout: 30 * time.Second}
478
+
sessionResp, err := client.Do(sessionReq)
479
+
if err != nil {
480
+
return nil, fmt.Errorf("failed to create session: %v", err)
481
+
}
482
+
defer sessionResp.Body.Close()
483
+
484
+
if sessionResp.StatusCode != http.StatusOK {
485
+
return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
486
+
}
487
+
488
+
var session session
489
+
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
490
+
return nil, fmt.Errorf("failed to decode session response: %v", err)
491
+
}
492
+
493
+
session.PdsEndpoint = pdsEndpoint
494
+
session.Did = did
495
+
496
+
return &session, nil
497
+
}
498
+
499
+
func (s *session) putRecord(record any, collection string) error {
500
+
recordBytes, err := json.Marshal(record)
501
+
if err != nil {
502
+
return fmt.Errorf("failed to marshal knot member record: %w", err)
503
+
}
504
+
505
+
payload := map[string]any{
506
+
"repo": s.Did,
507
+
"collection": collection,
508
+
"rkey": tid.TID(),
509
+
"record": json.RawMessage(recordBytes),
510
+
}
511
+
512
+
payloadBytes, err := json.Marshal(payload)
513
+
if err != nil {
514
+
return fmt.Errorf("failed to marshal request payload: %w", err)
515
+
}
516
+
517
+
url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
518
+
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
519
+
if err != nil {
520
+
return fmt.Errorf("failed to create HTTP request: %w", err)
521
+
}
522
+
523
+
req.Header.Set("Content-Type", "application/json")
524
+
req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
525
+
526
+
client := &http.Client{Timeout: 30 * time.Second}
527
+
resp, err := client.Do(req)
528
+
if err != nil {
529
+
return fmt.Errorf("failed to add user to default service: %w", err)
530
+
}
531
+
defer resp.Body.Close()
532
+
533
+
if resp.StatusCode != http.StatusOK {
534
+
return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode)
535
+
}
536
+
537
+
return nil
538
+
}
-278
appview/oauth/handler.go
-278
appview/oauth/handler.go
···
1
-
package oauth
2
-
3
-
import (
4
-
"bytes"
5
-
"context"
6
-
"encoding/json"
7
-
"errors"
8
-
"fmt"
9
-
"net/http"
10
-
"slices"
11
-
"time"
12
-
13
-
"github.com/bluesky-social/indigo/atproto/auth/oauth"
14
-
"github.com/go-chi/chi/v5"
15
-
"github.com/posthog/posthog-go"
16
-
"tangled.org/core/api/tangled"
17
-
"tangled.org/core/appview/db"
18
-
"tangled.org/core/consts"
19
-
"tangled.org/core/tid"
20
-
)
21
-
22
-
func (o *OAuth) Router() http.Handler {
23
-
r := chi.NewRouter()
24
-
25
-
r.Get("/oauth/client-metadata.json", o.clientMetadata)
26
-
r.Get("/oauth/jwks.json", o.jwks)
27
-
r.Get("/oauth/callback", o.callback)
28
-
return r
29
-
}
30
-
31
-
func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) {
32
-
doc := o.ClientApp.Config.ClientMetadata()
33
-
doc.JWKSURI = &o.JwksUri
34
-
doc.ClientName = &o.ClientName
35
-
doc.ClientURI = &o.ClientUri
36
-
37
-
w.Header().Set("Content-Type", "application/json")
38
-
if err := json.NewEncoder(w).Encode(doc); err != nil {
39
-
http.Error(w, err.Error(), http.StatusInternalServerError)
40
-
return
41
-
}
42
-
}
43
-
44
-
func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) {
45
-
w.Header().Set("Content-Type", "application/json")
46
-
body := o.ClientApp.Config.PublicJWKS()
47
-
if err := json.NewEncoder(w).Encode(body); err != nil {
48
-
http.Error(w, err.Error(), http.StatusInternalServerError)
49
-
return
50
-
}
51
-
}
52
-
53
-
func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
54
-
ctx := r.Context()
55
-
l := o.Logger.With("query", r.URL.Query())
56
-
57
-
sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query())
58
-
if err != nil {
59
-
var callbackErr *oauth.AuthRequestCallbackError
60
-
if errors.As(err, &callbackErr) {
61
-
l.Debug("callback error", "err", callbackErr)
62
-
http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound)
63
-
return
64
-
}
65
-
l.Error("failed to process callback", "err", err)
66
-
http.Redirect(w, r, "/login?error=oauth", http.StatusFound)
67
-
return
68
-
}
69
-
70
-
if err := o.SaveSession(w, r, sessData); err != nil {
71
-
l.Error("failed to save session", "data", sessData, "err", err)
72
-
http.Redirect(w, r, "/login?error=session", http.StatusFound)
73
-
return
74
-
}
75
-
76
-
o.Logger.Debug("session saved successfully")
77
-
go o.addToDefaultKnot(sessData.AccountDID.String())
78
-
go o.addToDefaultSpindle(sessData.AccountDID.String())
79
-
80
-
if !o.Config.Core.Dev {
81
-
err = o.Posthog.Enqueue(posthog.Capture{
82
-
DistinctId: sessData.AccountDID.String(),
83
-
Event: "signin",
84
-
})
85
-
if err != nil {
86
-
o.Logger.Error("failed to enqueue posthog event", "err", err)
87
-
}
88
-
}
89
-
90
-
http.Redirect(w, r, "/", http.StatusFound)
91
-
}
92
-
93
-
func (o *OAuth) addToDefaultSpindle(did string) {
94
-
l := o.Logger.With("subject", did)
95
-
96
-
// use the tangled.sh app password to get an accessJwt
97
-
// and create an sh.tangled.spindle.member record with that
98
-
spindleMembers, err := db.GetSpindleMembers(
99
-
o.Db,
100
-
db.FilterEq("instance", "spindle.tangled.sh"),
101
-
db.FilterEq("subject", did),
102
-
)
103
-
if err != nil {
104
-
l.Error("failed to get spindle members", "err", err)
105
-
return
106
-
}
107
-
108
-
if len(spindleMembers) != 0 {
109
-
l.Warn("already a member of the default spindle")
110
-
return
111
-
}
112
-
113
-
l.Debug("adding to default spindle")
114
-
session, err := o.createAppPasswordSession(o.Config.Core.AppPassword, consts.TangledDid)
115
-
if err != nil {
116
-
l.Error("failed to create session", "err", err)
117
-
return
118
-
}
119
-
120
-
record := tangled.SpindleMember{
121
-
LexiconTypeID: "sh.tangled.spindle.member",
122
-
Subject: did,
123
-
Instance: consts.DefaultSpindle,
124
-
CreatedAt: time.Now().Format(time.RFC3339),
125
-
}
126
-
127
-
if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
128
-
l.Error("failed to add to default spindle", "err", err)
129
-
return
130
-
}
131
-
132
-
l.Debug("successfully added to default spindle", "did", did)
133
-
}
134
-
135
-
func (o *OAuth) addToDefaultKnot(did string) {
136
-
l := o.Logger.With("subject", did)
137
-
138
-
// use the tangled.sh app password to get an accessJwt
139
-
// and create an sh.tangled.spindle.member record with that
140
-
141
-
allKnots, err := o.Enforcer.GetKnotsForUser(did)
142
-
if err != nil {
143
-
l.Error("failed to get knot members for did", "err", err)
144
-
return
145
-
}
146
-
147
-
if slices.Contains(allKnots, consts.DefaultKnot) {
148
-
l.Warn("already a member of the default knot")
149
-
return
150
-
}
151
-
152
-
l.Debug("addings to default knot")
153
-
session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid)
154
-
if err != nil {
155
-
l.Error("failed to create session", "err", err)
156
-
return
157
-
}
158
-
159
-
record := tangled.KnotMember{
160
-
LexiconTypeID: "sh.tangled.knot.member",
161
-
Subject: did,
162
-
Domain: consts.DefaultKnot,
163
-
CreatedAt: time.Now().Format(time.RFC3339),
164
-
}
165
-
166
-
if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
167
-
l.Error("failed to add to default knot", "err", err)
168
-
return
169
-
}
170
-
171
-
if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil {
172
-
l.Error("failed to set up enforcer rules", "err", err)
173
-
return
174
-
}
175
-
176
-
l.Debug("successfully addeds to default Knot")
177
-
}
178
-
179
-
// create a session using apppasswords
180
-
type session struct {
181
-
AccessJwt string `json:"accessJwt"`
182
-
PdsEndpoint string
183
-
Did string
184
-
}
185
-
186
-
func (o *OAuth) createAppPasswordSession(appPassword, did string) (*session, error) {
187
-
if appPassword == "" {
188
-
return nil, fmt.Errorf("no app password configured, skipping member addition")
189
-
}
190
-
191
-
resolved, err := o.IdResolver.ResolveIdent(context.Background(), did)
192
-
if err != nil {
193
-
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
194
-
}
195
-
196
-
pdsEndpoint := resolved.PDSEndpoint()
197
-
if pdsEndpoint == "" {
198
-
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
199
-
}
200
-
201
-
sessionPayload := map[string]string{
202
-
"identifier": did,
203
-
"password": appPassword,
204
-
}
205
-
sessionBytes, err := json.Marshal(sessionPayload)
206
-
if err != nil {
207
-
return nil, fmt.Errorf("failed to marshal session payload: %v", err)
208
-
}
209
-
210
-
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
211
-
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
212
-
if err != nil {
213
-
return nil, fmt.Errorf("failed to create session request: %v", err)
214
-
}
215
-
sessionReq.Header.Set("Content-Type", "application/json")
216
-
217
-
client := &http.Client{Timeout: 30 * time.Second}
218
-
sessionResp, err := client.Do(sessionReq)
219
-
if err != nil {
220
-
return nil, fmt.Errorf("failed to create session: %v", err)
221
-
}
222
-
defer sessionResp.Body.Close()
223
-
224
-
if sessionResp.StatusCode != http.StatusOK {
225
-
return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
226
-
}
227
-
228
-
var session session
229
-
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
230
-
return nil, fmt.Errorf("failed to decode session response: %v", err)
231
-
}
232
-
233
-
session.PdsEndpoint = pdsEndpoint
234
-
session.Did = did
235
-
236
-
return &session, nil
237
-
}
238
-
239
-
func (s *session) putRecord(record any, collection string) error {
240
-
recordBytes, err := json.Marshal(record)
241
-
if err != nil {
242
-
return fmt.Errorf("failed to marshal knot member record: %w", err)
243
-
}
244
-
245
-
payload := map[string]any{
246
-
"repo": s.Did,
247
-
"collection": collection,
248
-
"rkey": tid.TID(),
249
-
"record": json.RawMessage(recordBytes),
250
-
}
251
-
252
-
payloadBytes, err := json.Marshal(payload)
253
-
if err != nil {
254
-
return fmt.Errorf("failed to marshal request payload: %w", err)
255
-
}
256
-
257
-
url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
258
-
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
259
-
if err != nil {
260
-
return fmt.Errorf("failed to create HTTP request: %w", err)
261
-
}
262
-
263
-
req.Header.Set("Content-Type", "application/json")
264
-
req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
265
-
266
-
client := &http.Client{Timeout: 30 * time.Second}
267
-
resp, err := client.Do(req)
268
-
if err != nil {
269
-
return fmt.Errorf("failed to add user to default service: %w", err)
270
-
}
271
-
defer resp.Body.Close()
272
-
273
-
if resp.StatusCode != http.StatusOK {
274
-
return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode)
275
-
}
276
-
277
-
return nil
278
-
}
+203
-136
appview/oauth/oauth.go
+203
-136
appview/oauth/oauth.go
···
1
1
package oauth
2
2
3
3
import (
4
-
"errors"
5
4
"fmt"
6
-
"log/slog"
5
+
"log"
7
6
"net/http"
7
+
"net/url"
8
8
"time"
9
9
10
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
-
"github.com/bluesky-social/indigo/atproto/auth/oauth"
12
-
atpclient "github.com/bluesky-social/indigo/atproto/client"
13
-
atcrypto "github.com/bluesky-social/indigo/atproto/crypto"
14
-
"github.com/bluesky-social/indigo/atproto/syntax"
15
-
xrpc "github.com/bluesky-social/indigo/xrpc"
10
+
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
16
11
"github.com/gorilla/sessions"
17
-
"github.com/posthog/posthog-go"
12
+
sessioncache "tangled.org/core/appview/cache/session"
18
13
"tangled.org/core/appview/config"
19
-
"tangled.org/core/appview/db"
20
-
"tangled.org/core/idresolver"
21
-
"tangled.org/core/rbac"
14
+
"tangled.org/core/appview/oauth/client"
15
+
xrpc "tangled.org/core/appview/xrpcclient"
16
+
oauth "tangled.sh/icyphox.sh/atproto-oauth"
17
+
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
22
18
)
23
19
24
20
type OAuth struct {
25
-
ClientApp *oauth.ClientApp
26
-
SessStore *sessions.CookieStore
27
-
Config *config.Config
28
-
JwksUri string
29
-
ClientName string
30
-
ClientUri string
31
-
Posthog posthog.Client
32
-
Db *db.DB
33
-
Enforcer *rbac.Enforcer
34
-
IdResolver *idresolver.Resolver
35
-
Logger *slog.Logger
21
+
store *sessions.CookieStore
22
+
config *config.Config
23
+
sess *sessioncache.SessionStore
36
24
}
37
25
38
-
func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) {
39
-
var oauthConfig oauth.ClientConfig
40
-
var clientUri string
41
-
if config.Core.Dev {
42
-
clientUri = "http://127.0.0.1:3000"
43
-
callbackUri := clientUri + "/oauth/callback"
44
-
oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"})
45
-
} else {
46
-
clientUri = config.Core.AppviewHost
47
-
clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri)
48
-
callbackUri := clientUri + "/oauth/callback"
49
-
oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"})
26
+
func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth {
27
+
return &OAuth{
28
+
store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)),
29
+
config: config,
30
+
sess: sess,
50
31
}
32
+
}
51
33
52
-
// configure client secret
53
-
priv, err := atcrypto.ParsePrivateMultibase(config.OAuth.ClientSecret)
34
+
func (o *OAuth) Stores() *sessions.CookieStore {
35
+
return o.store
36
+
}
37
+
38
+
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error {
39
+
// first we save the did in the user session
40
+
userSession, err := o.store.Get(r, SessionName)
54
41
if err != nil {
55
-
return nil, err
56
-
}
57
-
if err := oauthConfig.SetClientSecret(priv, config.OAuth.ClientKid); err != nil {
58
-
return nil, err
42
+
return err
59
43
}
60
44
61
-
jwksUri := clientUri + "/oauth/jwks.json"
62
-
63
-
authStore, err := NewRedisStore(&RedisStoreConfig{
64
-
RedisURL: config.Redis.ToURL(),
65
-
SessionExpiryDuration: time.Hour * 24 * 90,
66
-
SessionInactivityDuration: time.Hour * 24 * 14,
67
-
AuthRequestExpiryDuration: time.Minute * 30,
68
-
})
45
+
userSession.Values[SessionDid] = oreq.Did
46
+
userSession.Values[SessionHandle] = oreq.Handle
47
+
userSession.Values[SessionPds] = oreq.PdsUrl
48
+
userSession.Values[SessionAuthenticated] = true
49
+
err = userSession.Save(r, w)
69
50
if err != nil {
70
-
return nil, err
51
+
return fmt.Errorf("error saving user session: %w", err)
71
52
}
72
53
73
-
sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret))
74
-
75
-
clientApp := oauth.NewClientApp(&oauthConfig, authStore)
76
-
clientApp.Dir = res.Directory()
77
-
// allow non-public transports in dev mode
78
-
if config.Core.Dev {
79
-
clientApp.Resolver.Client.Transport = http.DefaultTransport
54
+
// then save the whole thing in the db
55
+
session := sessioncache.OAuthSession{
56
+
Did: oreq.Did,
57
+
Handle: oreq.Handle,
58
+
PdsUrl: oreq.PdsUrl,
59
+
DpopAuthserverNonce: oreq.DpopAuthserverNonce,
60
+
AuthServerIss: oreq.AuthserverIss,
61
+
DpopPrivateJwk: oreq.DpopPrivateJwk,
62
+
AccessJwt: oresp.AccessToken,
63
+
RefreshJwt: oresp.RefreshToken,
64
+
Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339),
80
65
}
81
66
82
-
clientName := config.Core.AppviewName
67
+
return o.sess.SaveSession(r.Context(), session)
68
+
}
69
+
70
+
func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error {
71
+
userSession, err := o.store.Get(r, SessionName)
72
+
if err != nil || userSession.IsNew {
73
+
return fmt.Errorf("error getting user session (or new session?): %w", err)
74
+
}
83
75
84
-
logger.Info("oauth setup successfully", "IsConfidential", clientApp.Config.IsConfidential())
85
-
return &OAuth{
86
-
ClientApp: clientApp,
87
-
Config: config,
88
-
SessStore: sessStore,
89
-
JwksUri: jwksUri,
90
-
ClientName: clientName,
91
-
ClientUri: clientUri,
92
-
Posthog: ph,
93
-
Db: db,
94
-
Enforcer: enforcer,
95
-
IdResolver: res,
96
-
Logger: logger,
97
-
}, nil
98
-
}
76
+
did := userSession.Values[SessionDid].(string)
99
77
100
-
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error {
101
-
// first we save the did in the user session
102
-
userSession, err := o.SessStore.Get(r, SessionName)
78
+
err = o.sess.DeleteSession(r.Context(), did)
103
79
if err != nil {
104
-
return err
80
+
return fmt.Errorf("error deleting oauth session: %w", err)
105
81
}
106
82
107
-
userSession.Values[SessionDid] = sessData.AccountDID.String()
108
-
userSession.Values[SessionPds] = sessData.HostURL
109
-
userSession.Values[SessionId] = sessData.SessionID
110
-
userSession.Values[SessionAuthenticated] = true
83
+
userSession.Options.MaxAge = -1
84
+
111
85
return userSession.Save(r, w)
112
86
}
113
87
114
-
func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) {
115
-
userSession, err := o.SessStore.Get(r, SessionName)
116
-
if err != nil {
117
-
return nil, fmt.Errorf("error getting user session: %w", err)
118
-
}
119
-
if userSession.IsNew {
120
-
return nil, fmt.Errorf("no session available for user")
88
+
func (o *OAuth) GetSession(r *http.Request) (*sessioncache.OAuthSession, bool, error) {
89
+
userSession, err := o.store.Get(r, SessionName)
90
+
if err != nil || userSession.IsNew {
91
+
return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err)
121
92
}
122
93
123
-
d := userSession.Values[SessionDid].(string)
124
-
sessDid, err := syntax.ParseDID(d)
94
+
did := userSession.Values[SessionDid].(string)
95
+
auth := userSession.Values[SessionAuthenticated].(bool)
96
+
97
+
session, err := o.sess.GetSession(r.Context(), did)
125
98
if err != nil {
126
-
return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
99
+
return nil, false, fmt.Errorf("error getting oauth session: %w", err)
127
100
}
128
101
129
-
sessId := userSession.Values[SessionId].(string)
130
-
131
-
clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId)
102
+
expiry, err := time.Parse(time.RFC3339, session.Expiry)
132
103
if err != nil {
133
-
return nil, fmt.Errorf("failed to resume session: %w", err)
104
+
return nil, false, fmt.Errorf("error parsing expiry time: %w", err)
134
105
}
106
+
if time.Until(expiry) <= 5*time.Minute {
107
+
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
108
+
if err != nil {
109
+
return nil, false, err
110
+
}
135
111
136
-
return clientSess, nil
137
-
}
112
+
self := o.ClientMetadata()
138
113
139
-
func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error {
140
-
userSession, err := o.SessStore.Get(r, SessionName)
141
-
if err != nil {
142
-
return fmt.Errorf("error getting user session: %w", err)
143
-
}
144
-
if userSession.IsNew {
145
-
return fmt.Errorf("no session available for user")
146
-
}
114
+
oauthClient, err := client.NewClient(
115
+
self.ClientID,
116
+
o.config.OAuth.Jwks,
117
+
self.RedirectURIs[0],
118
+
)
147
119
148
-
d := userSession.Values[SessionDid].(string)
149
-
sessDid, err := syntax.ParseDID(d)
150
-
if err != nil {
151
-
return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
152
-
}
120
+
if err != nil {
121
+
return nil, false, err
122
+
}
153
123
154
-
sessId := userSession.Values[SessionId].(string)
124
+
resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk)
125
+
if err != nil {
126
+
return nil, false, err
127
+
}
155
128
156
-
// delete the session
157
-
err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId)
129
+
newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339)
130
+
err = o.sess.RefreshSession(r.Context(), did, resp.AccessToken, resp.RefreshToken, newExpiry)
131
+
if err != nil {
132
+
return nil, false, fmt.Errorf("error refreshing oauth session: %w", err)
133
+
}
158
134
159
-
// remove the cookie
160
-
userSession.Options.MaxAge = -1
161
-
err2 := o.SessStore.Save(r, w, userSession)
135
+
// update the current session
136
+
session.AccessJwt = resp.AccessToken
137
+
session.RefreshJwt = resp.RefreshToken
138
+
session.DpopAuthserverNonce = resp.DpopAuthserverNonce
139
+
session.Expiry = newExpiry
140
+
}
162
141
163
-
return errors.Join(err1, err2)
142
+
return session, auth, nil
164
143
}
165
144
166
145
type User struct {
167
-
Did string
168
-
Pds string
146
+
Handle string
147
+
Did string
148
+
Pds string
169
149
}
170
150
171
-
func (o *OAuth) GetUser(r *http.Request) *User {
172
-
sess, err := o.ResumeSession(r)
173
-
if err != nil {
151
+
func (a *OAuth) GetUser(r *http.Request) *User {
152
+
clientSession, err := a.store.Get(r, SessionName)
153
+
154
+
if err != nil || clientSession.IsNew {
174
155
return nil
175
156
}
176
157
177
158
return &User{
178
-
Did: sess.Data.AccountDID.String(),
179
-
Pds: sess.Data.HostURL,
159
+
Handle: clientSession.Values[SessionHandle].(string),
160
+
Did: clientSession.Values[SessionDid].(string),
161
+
Pds: clientSession.Values[SessionPds].(string),
180
162
}
181
163
}
182
164
183
-
func (o *OAuth) GetDid(r *http.Request) string {
184
-
if u := o.GetUser(r); u != nil {
185
-
return u.Did
165
+
func (a *OAuth) GetDid(r *http.Request) string {
166
+
clientSession, err := a.store.Get(r, SessionName)
167
+
168
+
if err != nil || clientSession.IsNew {
169
+
return ""
186
170
}
187
171
188
-
return ""
172
+
return clientSession.Values[SessionDid].(string)
189
173
}
190
174
191
-
func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) {
192
-
session, err := o.ResumeSession(r)
175
+
func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) {
176
+
session, auth, err := o.GetSession(r)
193
177
if err != nil {
194
178
return nil, fmt.Errorf("error getting session: %w", err)
195
179
}
196
-
return session.APIClient(), nil
180
+
if !auth {
181
+
return nil, fmt.Errorf("not authorized")
182
+
}
183
+
184
+
client := &oauth.XrpcClient{
185
+
OnDpopPdsNonceChanged: func(did, newNonce string) {
186
+
err := o.sess.UpdateNonce(r.Context(), did, newNonce)
187
+
if err != nil {
188
+
log.Printf("error updating dpop pds nonce: %v", err)
189
+
}
190
+
},
191
+
}
192
+
193
+
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
194
+
if err != nil {
195
+
return nil, fmt.Errorf("error parsing private jwk: %w", err)
196
+
}
197
+
198
+
xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{
199
+
Did: session.Did,
200
+
PdsUrl: session.PdsUrl,
201
+
DpopPdsNonce: session.PdsUrl,
202
+
AccessToken: session.AccessJwt,
203
+
Issuer: session.AuthServerIss,
204
+
DpopPrivateJwk: privateJwk,
205
+
})
206
+
207
+
return xrpcClient, nil
197
208
}
198
209
210
+
// use this to create a client to communicate with knots or spindles
211
+
//
199
212
// this is a higher level abstraction on ServerGetServiceAuth
200
213
type ServiceClientOpts struct {
201
214
service string
···
246
259
return scheme + s.service
247
260
}
248
261
249
-
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
262
+
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) {
250
263
opts := ServiceClientOpts{}
251
264
for _, o := range os {
252
265
o(&opts)
253
266
}
254
267
255
-
client, err := o.AuthorizedClient(r)
268
+
authorizedClient, err := o.AuthorizedClient(r)
256
269
if err != nil {
257
270
return nil, err
258
271
}
···
263
276
opts.exp = sixty
264
277
}
265
278
266
-
resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm)
279
+
resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm)
267
280
if err != nil {
268
281
return nil, err
269
282
}
270
283
271
-
return &xrpc.Client{
272
-
Auth: &xrpc.AuthInfo{
284
+
return &indigo_xrpc.Client{
285
+
Auth: &indigo_xrpc.AuthInfo{
273
286
AccessJwt: resp.Token,
274
287
},
275
288
Host: opts.Host(),
···
278
291
},
279
292
}, nil
280
293
}
294
+
295
+
type ClientMetadata struct {
296
+
ClientID string `json:"client_id"`
297
+
ClientName string `json:"client_name"`
298
+
SubjectType string `json:"subject_type"`
299
+
ClientURI string `json:"client_uri"`
300
+
RedirectURIs []string `json:"redirect_uris"`
301
+
GrantTypes []string `json:"grant_types"`
302
+
ResponseTypes []string `json:"response_types"`
303
+
ApplicationType string `json:"application_type"`
304
+
DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
305
+
JwksURI string `json:"jwks_uri"`
306
+
Scope string `json:"scope"`
307
+
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
308
+
TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
309
+
}
310
+
311
+
func (o *OAuth) ClientMetadata() ClientMetadata {
312
+
makeRedirectURIs := func(c string) []string {
313
+
return []string{fmt.Sprintf("%s/oauth/callback", c)}
314
+
}
315
+
316
+
clientURI := o.config.Core.AppviewHost
317
+
clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI)
318
+
redirectURIs := makeRedirectURIs(clientURI)
319
+
320
+
if o.config.Core.Dev {
321
+
clientURI = "http://127.0.0.1:3000"
322
+
redirectURIs = makeRedirectURIs(clientURI)
323
+
324
+
query := url.Values{}
325
+
query.Add("redirect_uri", redirectURIs[0])
326
+
query.Add("scope", "atproto transition:generic")
327
+
clientID = fmt.Sprintf("http://localhost?%s", query.Encode())
328
+
}
329
+
330
+
jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI)
331
+
332
+
return ClientMetadata{
333
+
ClientID: clientID,
334
+
ClientName: "Tangled",
335
+
SubjectType: "public",
336
+
ClientURI: clientURI,
337
+
RedirectURIs: redirectURIs,
338
+
GrantTypes: []string{"authorization_code", "refresh_token"},
339
+
ResponseTypes: []string{"code"},
340
+
ApplicationType: "web",
341
+
DpopBoundAccessTokens: true,
342
+
JwksURI: jwksURI,
343
+
Scope: "atproto transition:generic",
344
+
TokenEndpointAuthMethod: "private_key_jwt",
345
+
TokenEndpointAuthSigningAlg: "ES256",
346
+
}
347
+
}
-246
appview/oauth/store.go
-246
appview/oauth/store.go
···
1
-
package oauth
2
-
3
-
import (
4
-
"context"
5
-
"encoding/json"
6
-
"fmt"
7
-
"time"
8
-
9
-
"github.com/bluesky-social/indigo/atproto/auth/oauth"
10
-
"github.com/bluesky-social/indigo/atproto/syntax"
11
-
"github.com/redis/go-redis/v9"
12
-
)
13
-
14
-
type RedisStoreConfig struct {
15
-
RedisURL string
16
-
17
-
// The purpose of these limits is to avoid dead sessions hanging around in the db indefinitely.
18
-
// The durations here should be *at least as long as* the expected duration of the oauth session itself.
19
-
SessionExpiryDuration time.Duration // duration since session creation (max TTL)
20
-
SessionInactivityDuration time.Duration // duration since last session update
21
-
AuthRequestExpiryDuration time.Duration // duration since auth request creation
22
-
}
23
-
24
-
// redis-backed implementation of ClientAuthStore.
25
-
type RedisStore struct {
26
-
client *redis.Client
27
-
cfg *RedisStoreConfig
28
-
}
29
-
30
-
var _ oauth.ClientAuthStore = &RedisStore{}
31
-
32
-
type sessionMetadata struct {
33
-
CreatedAt time.Time `json:"created_at"`
34
-
UpdatedAt time.Time `json:"updated_at"`
35
-
}
36
-
37
-
func NewRedisStore(cfg *RedisStoreConfig) (*RedisStore, error) {
38
-
if cfg == nil {
39
-
return nil, fmt.Errorf("missing cfg")
40
-
}
41
-
if cfg.RedisURL == "" {
42
-
return nil, fmt.Errorf("missing RedisURL")
43
-
}
44
-
if cfg.SessionExpiryDuration == 0 {
45
-
return nil, fmt.Errorf("missing SessionExpiryDuration")
46
-
}
47
-
if cfg.SessionInactivityDuration == 0 {
48
-
return nil, fmt.Errorf("missing SessionInactivityDuration")
49
-
}
50
-
if cfg.AuthRequestExpiryDuration == 0 {
51
-
return nil, fmt.Errorf("missing AuthRequestExpiryDuration")
52
-
}
53
-
54
-
opts, err := redis.ParseURL(cfg.RedisURL)
55
-
if err != nil {
56
-
return nil, fmt.Errorf("failed to parse redis URL: %w", err)
57
-
}
58
-
59
-
client := redis.NewClient(opts)
60
-
61
-
// test the connection
62
-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
63
-
defer cancel()
64
-
65
-
if err := client.Ping(ctx).Err(); err != nil {
66
-
return nil, fmt.Errorf("failed to connect to redis: %w", err)
67
-
}
68
-
69
-
return &RedisStore{
70
-
client: client,
71
-
cfg: cfg,
72
-
}, nil
73
-
}
74
-
75
-
func (r *RedisStore) Close() error {
76
-
return r.client.Close()
77
-
}
78
-
79
-
func sessionKey(did syntax.DID, sessionID string) string {
80
-
return fmt.Sprintf("oauth:session:%s:%s", did, sessionID)
81
-
}
82
-
83
-
func sessionMetadataKey(did syntax.DID, sessionID string) string {
84
-
return fmt.Sprintf("oauth:session_meta:%s:%s", did, sessionID)
85
-
}
86
-
87
-
func authRequestKey(state string) string {
88
-
return fmt.Sprintf("oauth:auth_request:%s", state)
89
-
}
90
-
91
-
func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
92
-
key := sessionKey(did, sessionID)
93
-
metaKey := sessionMetadataKey(did, sessionID)
94
-
95
-
// Check metadata for inactivity expiry
96
-
metaData, err := r.client.Get(ctx, metaKey).Bytes()
97
-
if err == redis.Nil {
98
-
return nil, fmt.Errorf("session not found: %s", did)
99
-
}
100
-
if err != nil {
101
-
return nil, fmt.Errorf("failed to get session metadata: %w", err)
102
-
}
103
-
104
-
var meta sessionMetadata
105
-
if err := json.Unmarshal(metaData, &meta); err != nil {
106
-
return nil, fmt.Errorf("failed to unmarshal session metadata: %w", err)
107
-
}
108
-
109
-
// Check if session has been inactive for too long
110
-
inactiveThreshold := time.Now().Add(-r.cfg.SessionInactivityDuration)
111
-
if meta.UpdatedAt.Before(inactiveThreshold) {
112
-
// Session is inactive, delete it
113
-
r.client.Del(ctx, key, metaKey)
114
-
return nil, fmt.Errorf("session expired due to inactivity: %s", did)
115
-
}
116
-
117
-
// Get the actual session data
118
-
data, err := r.client.Get(ctx, key).Bytes()
119
-
if err == redis.Nil {
120
-
return nil, fmt.Errorf("session not found: %s", did)
121
-
}
122
-
if err != nil {
123
-
return nil, fmt.Errorf("failed to get session: %w", err)
124
-
}
125
-
126
-
var sess oauth.ClientSessionData
127
-
if err := json.Unmarshal(data, &sess); err != nil {
128
-
return nil, fmt.Errorf("failed to unmarshal session: %w", err)
129
-
}
130
-
131
-
return &sess, nil
132
-
}
133
-
134
-
func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
135
-
key := sessionKey(sess.AccountDID, sess.SessionID)
136
-
metaKey := sessionMetadataKey(sess.AccountDID, sess.SessionID)
137
-
138
-
data, err := json.Marshal(sess)
139
-
if err != nil {
140
-
return fmt.Errorf("failed to marshal session: %w", err)
141
-
}
142
-
143
-
// Check if session already exists to preserve CreatedAt
144
-
var meta sessionMetadata
145
-
existingMetaData, err := r.client.Get(ctx, metaKey).Bytes()
146
-
if err == redis.Nil {
147
-
// New session
148
-
meta = sessionMetadata{
149
-
CreatedAt: time.Now(),
150
-
UpdatedAt: time.Now(),
151
-
}
152
-
} else if err != nil {
153
-
return fmt.Errorf("failed to check existing session metadata: %w", err)
154
-
} else {
155
-
// Existing session - preserve CreatedAt, update UpdatedAt
156
-
if err := json.Unmarshal(existingMetaData, &meta); err != nil {
157
-
return fmt.Errorf("failed to unmarshal existing session metadata: %w", err)
158
-
}
159
-
meta.UpdatedAt = time.Now()
160
-
}
161
-
162
-
// Calculate remaining TTL based on creation time
163
-
remainingTTL := r.cfg.SessionExpiryDuration - time.Since(meta.CreatedAt)
164
-
if remainingTTL <= 0 {
165
-
return fmt.Errorf("session has expired")
166
-
}
167
-
168
-
// Use the shorter of: remaining TTL or inactivity duration
169
-
ttl := min(r.cfg.SessionInactivityDuration, remainingTTL)
170
-
171
-
// Save session data
172
-
if err := r.client.Set(ctx, key, data, ttl).Err(); err != nil {
173
-
return fmt.Errorf("failed to save session: %w", err)
174
-
}
175
-
176
-
// Save metadata
177
-
metaData, err := json.Marshal(meta)
178
-
if err != nil {
179
-
return fmt.Errorf("failed to marshal session metadata: %w", err)
180
-
}
181
-
if err := r.client.Set(ctx, metaKey, metaData, ttl).Err(); err != nil {
182
-
return fmt.Errorf("failed to save session metadata: %w", err)
183
-
}
184
-
185
-
return nil
186
-
}
187
-
188
-
func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
189
-
key := sessionKey(did, sessionID)
190
-
metaKey := sessionMetadataKey(did, sessionID)
191
-
192
-
if err := r.client.Del(ctx, key, metaKey).Err(); err != nil {
193
-
return fmt.Errorf("failed to delete session: %w", err)
194
-
}
195
-
return nil
196
-
}
197
-
198
-
func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
199
-
key := authRequestKey(state)
200
-
data, err := r.client.Get(ctx, key).Bytes()
201
-
if err == redis.Nil {
202
-
return nil, fmt.Errorf("request info not found: %s", state)
203
-
}
204
-
if err != nil {
205
-
return nil, fmt.Errorf("failed to get auth request: %w", err)
206
-
}
207
-
208
-
var req oauth.AuthRequestData
209
-
if err := json.Unmarshal(data, &req); err != nil {
210
-
return nil, fmt.Errorf("failed to unmarshal auth request: %w", err)
211
-
}
212
-
213
-
return &req, nil
214
-
}
215
-
216
-
func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
217
-
key := authRequestKey(info.State)
218
-
219
-
// check if already exists (to match MemStore behavior)
220
-
exists, err := r.client.Exists(ctx, key).Result()
221
-
if err != nil {
222
-
return fmt.Errorf("failed to check auth request existence: %w", err)
223
-
}
224
-
if exists > 0 {
225
-
return fmt.Errorf("auth request already saved for state %s", info.State)
226
-
}
227
-
228
-
data, err := json.Marshal(info)
229
-
if err != nil {
230
-
return fmt.Errorf("failed to marshal auth request: %w", err)
231
-
}
232
-
233
-
if err := r.client.Set(ctx, key, data, r.cfg.AuthRequestExpiryDuration).Err(); err != nil {
234
-
return fmt.Errorf("failed to save auth request: %w", err)
235
-
}
236
-
237
-
return nil
238
-
}
239
-
240
-
func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
241
-
key := authRequestKey(state)
242
-
if err := r.client.Del(ctx, key).Err(); err != nil {
243
-
return fmt.Errorf("failed to delete auth request: %w", err)
244
-
}
245
-
return nil
246
-
}
-584
appview/ogcard/card.go
-584
appview/ogcard/card.go
···
1
-
// Copyright 2024 The Forgejo Authors. All rights reserved.
2
-
// Copyright 2025 The Tangled Authors -- repurposed for Tangled use.
3
-
// SPDX-License-Identifier: MIT
4
-
5
-
package ogcard
6
-
7
-
import (
8
-
"bytes"
9
-
"fmt"
10
-
"html/template"
11
-
"image"
12
-
"image/color"
13
-
"io"
14
-
"log"
15
-
"math"
16
-
"net/http"
17
-
"strings"
18
-
"sync"
19
-
"time"
20
-
21
-
"github.com/goki/freetype"
22
-
"github.com/goki/freetype/truetype"
23
-
"github.com/srwiley/oksvg"
24
-
"github.com/srwiley/rasterx"
25
-
"golang.org/x/image/draw"
26
-
"golang.org/x/image/font"
27
-
"tangled.org/core/appview/pages"
28
-
29
-
_ "golang.org/x/image/webp" // for processing webp images
30
-
)
31
-
32
-
type Card struct {
33
-
Img *image.RGBA
34
-
Font *truetype.Font
35
-
Margin int
36
-
Width int
37
-
Height int
38
-
}
39
-
40
-
var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
41
-
interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf")
42
-
if err != nil {
43
-
return nil, err
44
-
}
45
-
return truetype.Parse(interVar)
46
-
})
47
-
48
-
// DefaultSize returns the default size for a card
49
-
func DefaultSize() (int, int) {
50
-
return 1200, 630
51
-
}
52
-
53
-
// NewCard creates a new card with the given dimensions in pixels
54
-
func NewCard(width, height int) (*Card, error) {
55
-
img := image.NewRGBA(image.Rect(0, 0, width, height))
56
-
draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
57
-
58
-
font, err := fontCache()
59
-
if err != nil {
60
-
return nil, err
61
-
}
62
-
63
-
return &Card{
64
-
Img: img,
65
-
Font: font,
66
-
Margin: 0,
67
-
Width: width,
68
-
Height: height,
69
-
}, nil
70
-
}
71
-
72
-
// Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage
73
-
// size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer.
74
-
func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) {
75
-
bounds := c.Img.Bounds()
76
-
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
77
-
if vertical {
78
-
mid := (bounds.Dx() * percentage / 100) + bounds.Min.X
79
-
subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA)
80
-
subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
81
-
return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()},
82
-
&Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()}
83
-
}
84
-
mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y
85
-
subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA)
86
-
subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
87
-
return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()},
88
-
&Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()}
89
-
}
90
-
91
-
// SetMargin sets the margins for the card
92
-
func (c *Card) SetMargin(margin int) {
93
-
c.Margin = margin
94
-
}
95
-
96
-
type (
97
-
VAlign int64
98
-
HAlign int64
99
-
)
100
-
101
-
const (
102
-
Top VAlign = iota
103
-
Middle
104
-
Bottom
105
-
)
106
-
107
-
const (
108
-
Left HAlign = iota
109
-
Center
110
-
Right
111
-
)
112
-
113
-
// DrawText draws text within the card, respecting margins and alignment
114
-
func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) {
115
-
ft := freetype.NewContext()
116
-
ft.SetDPI(72)
117
-
ft.SetFont(c.Font)
118
-
ft.SetFontSize(sizePt)
119
-
ft.SetClip(c.Img.Bounds())
120
-
ft.SetDst(c.Img)
121
-
ft.SetSrc(image.NewUniform(textColor))
122
-
123
-
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
124
-
fontHeight := ft.PointToFixed(sizePt).Ceil()
125
-
126
-
bounds := c.Img.Bounds()
127
-
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
128
-
boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y
129
-
// draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box
130
-
131
-
// Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move
132
-
// on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires
133
-
// knowing the total height, which is related to how many lines we'll have.
134
-
lines := make([]string, 0)
135
-
textWords := strings.Split(text, " ")
136
-
currentLine := ""
137
-
heightTotal := 0
138
-
139
-
for {
140
-
if len(textWords) == 0 {
141
-
// Ran out of words.
142
-
if currentLine != "" {
143
-
heightTotal += fontHeight
144
-
lines = append(lines, currentLine)
145
-
}
146
-
break
147
-
}
148
-
149
-
nextWord := textWords[0]
150
-
proposedLine := currentLine
151
-
if proposedLine != "" {
152
-
proposedLine += " "
153
-
}
154
-
proposedLine += nextWord
155
-
156
-
proposedLineWidth := font.MeasureString(face, proposedLine)
157
-
if proposedLineWidth.Ceil() > boxWidth {
158
-
// no, proposed line is too big; we'll use the last "currentLine"
159
-
heightTotal += fontHeight
160
-
if currentLine != "" {
161
-
lines = append(lines, currentLine)
162
-
currentLine = ""
163
-
// leave nextWord in textWords and keep going
164
-
} else {
165
-
// just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it
166
-
// regardless as a line by itself. It will be clipped by the drawing routine.
167
-
lines = append(lines, nextWord)
168
-
textWords = textWords[1:]
169
-
}
170
-
} else {
171
-
// yes, it will fit
172
-
currentLine = proposedLine
173
-
textWords = textWords[1:]
174
-
}
175
-
}
176
-
177
-
textY := 0
178
-
switch valign {
179
-
case Top:
180
-
textY = fontHeight
181
-
case Bottom:
182
-
textY = boxHeight - heightTotal + fontHeight
183
-
case Middle:
184
-
textY = ((boxHeight - heightTotal) / 2) + fontHeight
185
-
}
186
-
187
-
for _, line := range lines {
188
-
lineWidth := font.MeasureString(face, line)
189
-
190
-
textX := 0
191
-
switch halign {
192
-
case Left:
193
-
textX = 0
194
-
case Right:
195
-
textX = boxWidth - lineWidth.Ceil()
196
-
case Center:
197
-
textX = (boxWidth - lineWidth.Ceil()) / 2
198
-
}
199
-
200
-
pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY)
201
-
_, err := ft.DrawString(line, pt)
202
-
if err != nil {
203
-
return nil, err
204
-
}
205
-
206
-
textY += fontHeight
207
-
}
208
-
209
-
return lines, nil
210
-
}
211
-
212
-
// DrawTextAt draws text at a specific position with the given alignment
213
-
func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error {
214
-
_, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign)
215
-
return err
216
-
}
217
-
218
-
// DrawTextAtWithWidth draws text at a specific position and returns the text width
219
-
func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
220
-
ft := freetype.NewContext()
221
-
ft.SetDPI(72)
222
-
ft.SetFont(c.Font)
223
-
ft.SetFontSize(sizePt)
224
-
ft.SetClip(c.Img.Bounds())
225
-
ft.SetDst(c.Img)
226
-
ft.SetSrc(image.NewUniform(textColor))
227
-
228
-
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
229
-
fontHeight := ft.PointToFixed(sizePt).Ceil()
230
-
lineWidth := font.MeasureString(face, text)
231
-
textWidth := lineWidth.Ceil()
232
-
233
-
// Adjust position based on alignment
234
-
adjustedX := x
235
-
adjustedY := y
236
-
237
-
switch halign {
238
-
case Left:
239
-
// x is already at the left position
240
-
case Right:
241
-
adjustedX = x - textWidth
242
-
case Center:
243
-
adjustedX = x - textWidth/2
244
-
}
245
-
246
-
switch valign {
247
-
case Top:
248
-
adjustedY = y + fontHeight
249
-
case Bottom:
250
-
adjustedY = y
251
-
case Middle:
252
-
adjustedY = y + fontHeight/2
253
-
}
254
-
255
-
pt := freetype.Pt(adjustedX, adjustedY)
256
-
_, err := ft.DrawString(text, pt)
257
-
return textWidth, err
258
-
}
259
-
260
-
// DrawBoldText draws bold text by rendering multiple times with slight offsets
261
-
func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
262
-
// Draw the text multiple times with slight offsets to create bold effect
263
-
offsets := []struct{ dx, dy int }{
264
-
{0, 0}, // original
265
-
{1, 0}, // right
266
-
{0, 1}, // down
267
-
{1, 1}, // diagonal
268
-
}
269
-
270
-
var width int
271
-
for _, offset := range offsets {
272
-
w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign)
273
-
if err != nil {
274
-
return 0, err
275
-
}
276
-
if width == 0 {
277
-
width = w
278
-
}
279
-
}
280
-
return width, nil
281
-
}
282
-
283
-
func BuildSVGIconFromData(svgData []byte, iconColor color.Color) (*oksvg.SvgIcon, error) {
284
-
// Convert color to hex string for SVG
285
-
rgba, isRGBA := iconColor.(color.RGBA)
286
-
if !isRGBA {
287
-
r, g, b, a := iconColor.RGBA()
288
-
rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
289
-
}
290
-
colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
291
-
292
-
// Replace currentColor with our desired color in the SVG
293
-
svgString := string(svgData)
294
-
svgString = strings.ReplaceAll(svgString, "currentColor", colorHex)
295
-
296
-
// Make the stroke thicker
297
-
svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`)
298
-
299
-
// Parse SVG
300
-
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
301
-
if err != nil {
302
-
return nil, fmt.Errorf("failed to parse SVG: %w", err)
303
-
}
304
-
305
-
return icon, nil
306
-
}
307
-
308
-
func BuildSVGIconFromPath(svgPath string, iconColor color.Color) (*oksvg.SvgIcon, error) {
309
-
svgData, err := pages.Files.ReadFile(svgPath)
310
-
if err != nil {
311
-
return nil, fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
312
-
}
313
-
314
-
icon, err := BuildSVGIconFromData(svgData, iconColor)
315
-
if err != nil {
316
-
return nil, fmt.Errorf("failed to build SVG icon %s: %w", svgPath, err)
317
-
}
318
-
319
-
return icon, nil
320
-
}
321
-
322
-
func BuildLucideIcon(name string, iconColor color.Color) (*oksvg.SvgIcon, error) {
323
-
return BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor)
324
-
}
325
-
326
-
func (c *Card) DrawLucideIcon(name string, x, y, size int, iconColor color.Color) error {
327
-
icon, err := BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor)
328
-
if err != nil {
329
-
return err
330
-
}
331
-
332
-
c.DrawSVGIcon(icon, x, y, size)
333
-
334
-
return nil
335
-
}
336
-
337
-
func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error {
338
-
tpl, err := template.New("dolly").
339
-
ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html")
340
-
if err != nil {
341
-
return fmt.Errorf("failed to read dolly silhouette template: %w", err)
342
-
}
343
-
344
-
var svgData bytes.Buffer
345
-
if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil {
346
-
return fmt.Errorf("failed to execute dolly silhouette template: %w", err)
347
-
}
348
-
349
-
icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor)
350
-
if err != nil {
351
-
return err
352
-
}
353
-
354
-
c.DrawSVGIcon(icon, x, y, size)
355
-
356
-
return nil
357
-
}
358
-
359
-
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
360
-
func (c *Card) DrawSVGIcon(icon *oksvg.SvgIcon, x, y, size int) {
361
-
// Set the icon size
362
-
w, h := float64(size), float64(size)
363
-
icon.SetTarget(0, 0, w, h)
364
-
365
-
// Create a temporary RGBA image for the icon
366
-
iconImg := image.NewRGBA(image.Rect(0, 0, size, size))
367
-
368
-
// Create scanner and rasterizer
369
-
scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds())
370
-
raster := rasterx.NewDasher(size, size, scanner)
371
-
372
-
// Draw the icon
373
-
icon.Draw(raster, 1.0)
374
-
375
-
// Draw the icon onto the card at the specified position
376
-
bounds := c.Img.Bounds()
377
-
destRect := image.Rect(x, y, x+size, y+size)
378
-
379
-
// Make sure we don't draw outside the card bounds
380
-
if destRect.Max.X > bounds.Max.X {
381
-
destRect.Max.X = bounds.Max.X
382
-
}
383
-
if destRect.Max.Y > bounds.Max.Y {
384
-
destRect.Max.Y = bounds.Max.Y
385
-
}
386
-
387
-
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
388
-
}
389
-
390
-
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
391
-
func (c *Card) DrawImage(img image.Image) {
392
-
bounds := c.Img.Bounds()
393
-
targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
394
-
srcBounds := img.Bounds()
395
-
srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
396
-
targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy())
397
-
398
-
var scale float64
399
-
if srcAspect > targetAspect {
400
-
// Image is wider than target, scale by width
401
-
scale = float64(targetRect.Dx()) / float64(srcBounds.Dx())
402
-
} else {
403
-
// Image is taller or equal, scale by height
404
-
scale = float64(targetRect.Dy()) / float64(srcBounds.Dy())
405
-
}
406
-
407
-
newWidth := int(math.Round(float64(srcBounds.Dx()) * scale))
408
-
newHeight := int(math.Round(float64(srcBounds.Dy()) * scale))
409
-
410
-
// Center the image within the target rectangle
411
-
offsetX := (targetRect.Dx() - newWidth) / 2
412
-
offsetY := (targetRect.Dy() - newHeight) / 2
413
-
414
-
scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight)
415
-
draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil)
416
-
}
417
-
418
-
func fallbackImage() image.Image {
419
-
// can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage
420
-
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
421
-
img.Set(0, 0, color.White)
422
-
return img
423
-
}
424
-
425
-
// As defensively as possible, attempt to load an image from a presumed external and untrusted URL
426
-
func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
427
-
// Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want
428
-
// this rendering process to be slowed down
429
-
client := &http.Client{
430
-
Timeout: 1 * time.Second, // 1 second timeout
431
-
}
432
-
433
-
resp, err := client.Get(url)
434
-
if err != nil {
435
-
log.Printf("error when fetching external image from %s: %v", url, err)
436
-
return nil, false
437
-
}
438
-
defer resp.Body.Close()
439
-
440
-
if resp.StatusCode != http.StatusOK {
441
-
log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status)
442
-
return nil, false
443
-
}
444
-
445
-
contentType := resp.Header.Get("Content-Type")
446
-
447
-
body := resp.Body
448
-
bodyBytes, err := io.ReadAll(body)
449
-
if err != nil {
450
-
log.Printf("error when fetching external image from %s: %v", url, err)
451
-
return nil, false
452
-
}
453
-
454
-
// Handle SVG separately
455
-
if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") {
456
-
return c.convertSVGToPNG(bodyBytes)
457
-
}
458
-
459
-
// Support content types are in-sync with the allowed custom avatar file types
460
-
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
461
-
log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
462
-
return nil, false
463
-
}
464
-
465
-
bodyBuffer := bytes.NewReader(bodyBytes)
466
-
_, imgType, err := image.DecodeConfig(bodyBuffer)
467
-
if err != nil {
468
-
log.Printf("error when decoding external image from %s: %v", url, err)
469
-
return nil, false
470
-
}
471
-
472
-
// Verify that we have a match between actual data understood in the image body and the reported Content-Type
473
-
if (contentType == "image/png" && imgType != "png") ||
474
-
(contentType == "image/jpeg" && imgType != "jpeg") ||
475
-
(contentType == "image/gif" && imgType != "gif") ||
476
-
(contentType == "image/webp" && imgType != "webp") {
477
-
log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType)
478
-
return nil, false
479
-
}
480
-
481
-
_, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode
482
-
if err != nil {
483
-
log.Printf("error w/ bodyBuffer.Seek")
484
-
return nil, false
485
-
}
486
-
img, _, err := image.Decode(bodyBuffer)
487
-
if err != nil {
488
-
log.Printf("error when decoding external image from %s: %v", url, err)
489
-
return nil, false
490
-
}
491
-
492
-
return img, true
493
-
}
494
-
495
-
// convertSVGToPNG converts SVG data to a PNG image
496
-
func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) {
497
-
// Parse the SVG
498
-
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
499
-
if err != nil {
500
-
log.Printf("error parsing SVG: %v", err)
501
-
return nil, false
502
-
}
503
-
504
-
// Set a reasonable size for the rasterized image
505
-
width := 256
506
-
height := 256
507
-
icon.SetTarget(0, 0, float64(width), float64(height))
508
-
509
-
// Create an image to draw on
510
-
rgba := image.NewRGBA(image.Rect(0, 0, width, height))
511
-
512
-
// Fill with white background
513
-
draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
514
-
515
-
// Create a scanner and rasterize the SVG
516
-
scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds())
517
-
raster := rasterx.NewDasher(width, height, scanner)
518
-
519
-
icon.Draw(raster, 1.0)
520
-
521
-
return rgba, true
522
-
}
523
-
524
-
func (c *Card) DrawExternalImage(url string) {
525
-
image, ok := c.fetchExternalImage(url)
526
-
if !ok {
527
-
image = fallbackImage()
528
-
}
529
-
c.DrawImage(image)
530
-
}
531
-
532
-
// DrawCircularExternalImage draws an external image as a circle at the specified position
533
-
func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error {
534
-
img, ok := c.fetchExternalImage(url)
535
-
if !ok {
536
-
img = fallbackImage()
537
-
}
538
-
539
-
// Create a circular mask
540
-
circle := image.NewRGBA(image.Rect(0, 0, size, size))
541
-
center := size / 2
542
-
radius := float64(size / 2)
543
-
544
-
// Scale the source image to fit the circle
545
-
srcBounds := img.Bounds()
546
-
scaledImg := image.NewRGBA(image.Rect(0, 0, size, size))
547
-
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
548
-
549
-
// Draw the image with circular clipping
550
-
for cy := 0; cy < size; cy++ {
551
-
for cx := 0; cx < size; cx++ {
552
-
// Calculate distance from center
553
-
dx := float64(cx - center)
554
-
dy := float64(cy - center)
555
-
distance := math.Sqrt(dx*dx + dy*dy)
556
-
557
-
// Only draw pixels within the circle
558
-
if distance <= radius {
559
-
circle.Set(cx, cy, scaledImg.At(cx, cy))
560
-
}
561
-
}
562
-
}
563
-
564
-
// Draw the circle onto the card
565
-
bounds := c.Img.Bounds()
566
-
destRect := image.Rect(x, y, x+size, y+size)
567
-
568
-
// Make sure we don't draw outside the card bounds
569
-
if destRect.Max.X > bounds.Max.X {
570
-
destRect.Max.X = bounds.Max.X
571
-
}
572
-
if destRect.Max.Y > bounds.Max.Y {
573
-
destRect.Max.Y = bounds.Max.Y
574
-
}
575
-
576
-
draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over)
577
-
578
-
return nil
579
-
}
580
-
581
-
// DrawRect draws a rect with the given color
582
-
func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) {
583
-
draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src)
584
-
}
+13
-72
appview/pages/funcmap.go
+13
-72
appview/pages/funcmap.go
···
1
1
package pages
2
2
3
3
import (
4
-
"bytes"
5
4
"context"
6
5
"crypto/hmac"
7
6
"crypto/sha256"
···
18
17
"strings"
19
18
"time"
20
19
21
-
"github.com/alecthomas/chroma/v2"
22
-
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
23
-
"github.com/alecthomas/chroma/v2/lexers"
24
-
"github.com/alecthomas/chroma/v2/styles"
25
-
"github.com/bluesky-social/indigo/atproto/syntax"
26
20
"github.com/dustin/go-humanize"
27
21
"github.com/go-enry/go-enry/v2"
28
22
"tangled.org/core/appview/filetree"
···
44
38
"contains": func(s string, target string) bool {
45
39
return strings.Contains(s, target)
46
40
},
47
-
"stripPort": func(hostname string) string {
48
-
if strings.Contains(hostname, ":") {
49
-
return strings.Split(hostname, ":")[0]
50
-
}
51
-
return hostname
52
-
},
53
41
"mapContains": func(m any, key any) bool {
54
42
mapValue := reflect.ValueOf(m)
55
43
if mapValue.Kind() != reflect.Map {
···
69
57
return "handle.invalid"
70
58
}
71
59
72
-
return identity.Handle.String()
60
+
return "@" + identity.Handle.String()
73
61
},
74
62
"truncateAt30": func(s string) string {
75
63
if len(s) <= 30 {
···
79
67
},
80
68
"splitOn": func(s, sep string) []string {
81
69
return strings.Split(s, sep)
82
-
},
83
-
"string": func(v any) string {
84
-
return fmt.Sprint(v)
85
70
},
86
71
"int64": func(a int) int64 {
87
72
return int64(a)
···
132
117
return b
133
118
},
134
119
"didOrHandle": func(did, handle string) string {
135
-
if handle != "" && handle != syntax.HandleInvalid.String() {
136
-
return handle
120
+
if handle != "" {
121
+
return fmt.Sprintf("@%s", handle)
137
122
} else {
138
123
return did
139
124
}
···
251
236
sanitized := p.rctx.SanitizeDescription(htmlString)
252
237
return template.HTML(sanitized)
253
238
},
254
-
"readme": func(text string) template.HTML {
255
-
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
256
-
htmlString := p.rctx.RenderMarkdown(text)
257
-
sanitized := p.rctx.SanitizeDefault(htmlString)
258
-
return template.HTML(sanitized)
259
-
},
260
-
"code": func(content, path string) string {
261
-
var style *chroma.Style = styles.Get("catpuccin-latte")
262
-
formatter := chromahtml.New(
263
-
chromahtml.InlineCode(false),
264
-
chromahtml.WithLineNumbers(true),
265
-
chromahtml.WithLinkableLineNumbers(true, "L"),
266
-
chromahtml.Standalone(false),
267
-
chromahtml.WithClasses(true),
268
-
)
269
-
270
-
lexer := lexers.Get(filepath.Base(path))
271
-
if lexer == nil {
272
-
lexer = lexers.Fallback
273
-
}
274
-
275
-
iterator, err := lexer.Tokenise(nil, content)
276
-
if err != nil {
277
-
p.logger.Error("chroma tokenize", "err", "err")
278
-
return ""
279
-
}
280
-
281
-
var code bytes.Buffer
282
-
err = formatter.Format(&code, style, iterator)
283
-
if err != nil {
284
-
p.logger.Error("chroma format", "err", "err")
285
-
return ""
286
-
}
287
-
288
-
return code.String()
289
-
},
290
-
"trimUriScheme": func(text string) string {
291
-
text = strings.TrimPrefix(text, "https://")
292
-
text = strings.TrimPrefix(text, "http://")
293
-
return text
294
-
},
295
239
"isNil": func(t any) bool {
296
240
// returns false for other "zero" values
297
241
return t == nil
···
321
265
return nil
322
266
},
323
267
"i": func(name string, classes ...string) template.HTML {
324
-
data, err := p.icon(name, classes)
268
+
data, err := icon(name, classes)
325
269
if err != nil {
326
270
log.Printf("icon %s does not exist", name)
327
-
data, _ = p.icon("airplay", classes)
271
+
data, _ = icon("airplay", classes)
328
272
}
329
273
return template.HTML(data)
330
274
},
331
-
"cssContentHash": p.CssContentHash,
275
+
"cssContentHash": CssContentHash,
332
276
"fileTree": filetree.FileTree,
333
277
"pathEscape": func(s string) string {
334
278
return url.PathEscape(s)
···
337
281
u, _ := url.PathUnescape(s)
338
282
return u
339
283
},
340
-
"safeUrl": func(s string) template.URL {
341
-
return template.URL(s)
342
-
},
284
+
343
285
"tinyAvatar": func(handle string) string {
344
-
return p.AvatarUrl(handle, "tiny")
286
+
return p.avatarUri(handle, "tiny")
345
287
},
346
288
"fullAvatar": func(handle string) string {
347
-
return p.AvatarUrl(handle, "")
289
+
return p.avatarUri(handle, "")
348
290
},
349
291
"langColor": enry.GetColor,
350
292
"layoutSide": func() string {
···
355
297
},
356
298
357
299
"normalizeForHtmlId": func(s string) string {
358
-
normalized := strings.ReplaceAll(s, ":", "_")
359
-
normalized = strings.ReplaceAll(normalized, ".", "_")
360
-
return normalized
300
+
// TODO: extend this to handle other cases?
301
+
return strings.ReplaceAll(s, ":", "_")
361
302
},
362
303
"sshFingerprint": func(pubKey string) string {
363
304
fp, err := crypto.SSHFingerprint(pubKey)
···
369
310
}
370
311
}
371
312
372
-
func (p *Pages) AvatarUrl(handle, size string) string {
313
+
func (p *Pages) avatarUri(handle, size string) string {
373
314
handle = strings.TrimPrefix(handle, "@")
374
315
375
316
secret := p.avatar.SharedSecret
···
384
325
return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg)
385
326
}
386
327
387
-
func (p *Pages) icon(name string, classes []string) (template.HTML, error) {
328
+
func icon(name string, classes []string) (template.HTML, error) {
388
329
iconPath := filepath.Join("static", "icons", name)
389
330
390
331
if filepath.Ext(name) == "" {
+2
-5
appview/pages/funcmap_test.go
+2
-5
appview/pages/funcmap_test.go
···
2
2
3
3
import (
4
4
"html/template"
5
-
"log/slog"
6
-
"testing"
7
-
8
5
"tangled.org/core/appview/config"
9
6
"tangled.org/core/idresolver"
7
+
"testing"
10
8
)
11
9
12
10
func TestPages_funcMap(t *testing.T) {
···
15
13
// Named input parameters for receiver constructor.
16
14
config *config.Config
17
15
res *idresolver.Resolver
18
-
l *slog.Logger
19
16
want template.FuncMap
20
17
}{
21
18
// TODO: Add test cases.
22
19
}
23
20
for _, tt := range tests {
24
21
t.Run(tt.name, func(t *testing.T) {
25
-
p := NewPages(tt.config, tt.res, tt.l)
22
+
p := NewPages(tt.config, tt.res)
26
23
got := p.funcMap()
27
24
// TODO: update the condition below to compare got with tt.want.
28
25
if true {
-111
appview/pages/markup/extension/atlink.go
-111
appview/pages/markup/extension/atlink.go
···
1
-
// heavily inspired by: https://github.com/kaleocheng/goldmark-extensions
2
-
3
-
package extension
4
-
5
-
import (
6
-
"regexp"
7
-
8
-
"github.com/yuin/goldmark"
9
-
"github.com/yuin/goldmark/ast"
10
-
"github.com/yuin/goldmark/parser"
11
-
"github.com/yuin/goldmark/renderer"
12
-
"github.com/yuin/goldmark/renderer/html"
13
-
"github.com/yuin/goldmark/text"
14
-
"github.com/yuin/goldmark/util"
15
-
)
16
-
17
-
// An AtNode struct represents an AtNode
18
-
type AtNode struct {
19
-
Handle string
20
-
ast.BaseInline
21
-
}
22
-
23
-
var _ ast.Node = &AtNode{}
24
-
25
-
// Dump implements Node.Dump.
26
-
func (n *AtNode) Dump(source []byte, level int) {
27
-
ast.DumpHelper(n, source, level, nil, nil)
28
-
}
29
-
30
-
// KindAt is a NodeKind of the At node.
31
-
var KindAt = ast.NewNodeKind("At")
32
-
33
-
// Kind implements Node.Kind.
34
-
func (n *AtNode) Kind() ast.NodeKind {
35
-
return KindAt
36
-
}
37
-
38
-
var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`)
39
-
40
-
type atParser struct{}
41
-
42
-
// NewAtParser return a new InlineParser that parses
43
-
// at expressions.
44
-
func NewAtParser() parser.InlineParser {
45
-
return &atParser{}
46
-
}
47
-
48
-
func (s *atParser) Trigger() []byte {
49
-
return []byte{'@'}
50
-
}
51
-
52
-
func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
53
-
line, segment := block.PeekLine()
54
-
m := atRegexp.FindSubmatchIndex(line)
55
-
if m == nil {
56
-
return nil
57
-
}
58
-
atSegment := text.NewSegment(segment.Start, segment.Start+m[1])
59
-
block.Advance(m[1])
60
-
node := &AtNode{}
61
-
node.AppendChild(node, ast.NewTextSegment(atSegment))
62
-
node.Handle = string(atSegment.Value(block.Source())[1:])
63
-
return node
64
-
}
65
-
66
-
// atHtmlRenderer is a renderer.NodeRenderer implementation that
67
-
// renders At nodes.
68
-
type atHtmlRenderer struct {
69
-
html.Config
70
-
}
71
-
72
-
// NewAtHTMLRenderer returns a new AtHTMLRenderer.
73
-
func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
74
-
r := &atHtmlRenderer{
75
-
Config: html.NewConfig(),
76
-
}
77
-
for _, opt := range opts {
78
-
opt.SetHTMLOption(&r.Config)
79
-
}
80
-
return r
81
-
}
82
-
83
-
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
84
-
func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
85
-
reg.Register(KindAt, r.renderAt)
86
-
}
87
-
88
-
func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
89
-
if entering {
90
-
w.WriteString(`<a href="/@`)
91
-
w.WriteString(n.(*AtNode).Handle)
92
-
w.WriteString(`" class="mention">`)
93
-
} else {
94
-
w.WriteString("</a>")
95
-
}
96
-
return ast.WalkContinue, nil
97
-
}
98
-
99
-
type atExt struct{}
100
-
101
-
// At is an extension that allow you to use at expression like '@user.bsky.social' .
102
-
var AtExt = &atExt{}
103
-
104
-
func (e *atExt) Extend(m goldmark.Markdown) {
105
-
m.Parser().AddOptions(parser.WithInlineParsers(
106
-
util.Prioritized(NewAtParser(), 500),
107
-
))
108
-
m.Renderer().AddOptions(renderer.WithNodeRenderers(
109
-
util.Prioritized(NewAtHTMLRenderer(), 500),
110
-
))
111
-
}
+2
-38
appview/pages/markup/markdown.go
+2
-38
appview/pages/markup/markdown.go
···
5
5
"bytes"
6
6
"fmt"
7
7
"io"
8
-
"io/fs"
9
8
"net/url"
10
9
"path"
11
10
"strings"
···
21
20
"github.com/yuin/goldmark/renderer/html"
22
21
"github.com/yuin/goldmark/text"
23
22
"github.com/yuin/goldmark/util"
24
-
callout "gitlab.com/staticnoise/goldmark-callout"
25
23
htmlparse "golang.org/x/net/html"
26
24
27
25
"tangled.org/core/api/tangled"
28
-
textension "tangled.org/core/appview/pages/markup/extension"
29
26
"tangled.org/core/appview/pages/repoinfo"
30
27
)
31
28
···
48
45
IsDev bool
49
46
RendererType RendererType
50
47
Sanitizer Sanitizer
51
-
Files fs.FS
52
48
}
53
49
54
-
func NewMarkdown() goldmark.Markdown {
50
+
func (rctx *RenderContext) RenderMarkdown(source string) string {
55
51
md := goldmark.New(
56
52
goldmark.WithExtensions(
57
53
extension.GFM,
···
66
62
extension.WithFootnoteIDPrefix([]byte("footnote")),
67
63
),
68
64
treeblood.MathML(),
69
-
callout.CalloutExtention,
70
-
textension.AtExt,
71
65
),
72
66
goldmark.WithParserOptions(
73
67
parser.WithAutoHeadingID(),
74
68
),
75
69
goldmark.WithRendererOptions(html.WithUnsafe()),
76
70
)
77
-
return md
78
-
}
79
-
80
-
func (rctx *RenderContext) RenderMarkdown(source string) string {
81
-
md := NewMarkdown()
82
71
83
72
if rctx != nil {
84
73
var transformers []util.PrioritizedValue
···
151
140
func visitNode(ctx *RenderContext, node *htmlparse.Node) {
152
141
switch node.Type {
153
142
case htmlparse.ElementNode:
154
-
switch node.Data {
155
-
case "img", "source":
143
+
if node.Data == "img" || node.Data == "source" {
156
144
for i, attr := range node.Attr {
157
145
if attr.Key != "src" {
158
146
continue
···
300
288
}
301
289
302
290
return path.Join(rctx.CurrentDir, dst)
303
-
}
304
-
305
-
// FindUserMentions returns Set of user handles from given markup soruce.
306
-
// It doesn't guarntee unique DIDs
307
-
func FindUserMentions(source string) []string {
308
-
var (
309
-
mentions []string
310
-
mentionsSet = make(map[string]struct{})
311
-
md = NewMarkdown()
312
-
sourceBytes = []byte(source)
313
-
root = md.Parser().Parse(text.NewReader(sourceBytes))
314
-
)
315
-
ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
316
-
if entering && n.Kind() == textension.KindAt {
317
-
handle := n.(*textension.AtNode).Handle
318
-
mentionsSet[handle] = struct{}{}
319
-
return ast.WalkSkipChildren, nil
320
-
}
321
-
return ast.WalkContinue, nil
322
-
})
323
-
for handle := range mentionsSet {
324
-
mentions = append(mentions, handle)
325
-
}
326
-
return mentions
327
291
}
328
292
329
293
func isAbsoluteUrl(link string) bool {
-6
appview/pages/markup/sanitizer.go
-6
appview/pages/markup/sanitizer.go
···
77
77
policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8")
78
78
policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span")
79
79
80
-
// at-mentions
81
-
policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a")
82
-
83
80
// centering content
84
81
policy.AllowElements("center")
85
82
···
116
113
}
117
114
policy.AllowNoAttrs().OnElements(mathElements...)
118
115
policy.AllowAttrs(mathAttrs...).OnElements(mathElements...)
119
-
120
-
// goldmark-callout
121
-
policy.AllowAttrs("data-callout").OnElements("details")
122
116
123
117
return policy
124
118
}
+153
-90
appview/pages/pages.go
+153
-90
appview/pages/pages.go
···
1
1
package pages
2
2
3
3
import (
4
+
"bytes"
4
5
"crypto/sha256"
5
6
"embed"
6
7
"encoding/hex"
···
14
15
"path/filepath"
15
16
"strings"
16
17
"sync"
17
-
"time"
18
18
19
19
"tangled.org/core/api/tangled"
20
20
"tangled.org/core/appview/commitverify"
···
28
28
"tangled.org/core/patchutil"
29
29
"tangled.org/core/types"
30
30
31
+
"github.com/alecthomas/chroma/v2"
32
+
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
33
+
"github.com/alecthomas/chroma/v2/lexers"
34
+
"github.com/alecthomas/chroma/v2/styles"
31
35
"github.com/bluesky-social/indigo/atproto/identity"
32
36
"github.com/bluesky-social/indigo/atproto/syntax"
33
37
"github.com/go-git/go-git/v5/plumbing"
···
50
54
logger *slog.Logger
51
55
}
52
56
53
-
func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages {
57
+
func NewPages(config *config.Config, res *idresolver.Resolver) *Pages {
54
58
// initialized with safe defaults, can be overriden per use
55
59
rctx := &markup.RenderContext{
56
60
IsDev: config.Core.Dev,
57
61
CamoUrl: config.Camo.Host,
58
62
CamoSecret: config.Camo.SharedSecret,
59
63
Sanitizer: markup.NewSanitizer(),
60
-
Files: Files,
61
64
}
62
65
63
66
p := &Pages{
···
68
71
rctx: rctx,
69
72
resolver: res,
70
73
templateDir: "appview/pages",
71
-
logger: logger,
74
+
logger: slog.Default().With("component", "pages"),
72
75
}
73
76
74
77
if p.dev {
···
217
220
218
221
type LoginParams struct {
219
222
ReturnUrl string
220
-
ErrorCode string
221
223
}
222
224
223
225
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
304
306
LoggedInUser *oauth.User
305
307
Timeline []models.TimelineEvent
306
308
Repos []models.Repo
307
-
GfiLabel *models.LabelDefinition
308
309
}
309
310
310
311
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
311
312
return p.execute("timeline/timeline", w, params)
312
313
}
313
314
314
-
type GoodFirstIssuesParams struct {
315
-
LoggedInUser *oauth.User
316
-
Issues []models.Issue
317
-
RepoGroups []*models.RepoGroup
318
-
LabelDefs map[string]*models.LabelDefinition
319
-
GfiLabel *models.LabelDefinition
320
-
Page pagination.Page
321
-
}
322
-
323
-
func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error {
324
-
return p.execute("goodfirstissues/index", w, params)
325
-
}
326
-
327
315
type UserProfileSettingsParams struct {
328
316
LoggedInUser *oauth.User
329
317
Tabs []map[string]any
···
635
623
return p.executePlain("repo/fragments/repoStar", w, params)
636
624
}
637
625
626
+
type RepoDescriptionParams struct {
627
+
RepoInfo repoinfo.RepoInfo
628
+
}
629
+
630
+
func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
631
+
return p.executePlain("repo/fragments/editRepoDescription", w, params)
632
+
}
633
+
634
+
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
635
+
return p.executePlain("repo/fragments/repoDescription", w, params)
636
+
}
637
+
638
638
type RepoIndexParams struct {
639
639
LoggedInUser *oauth.User
640
640
RepoInfo repoinfo.RepoInfo
···
644
644
TagsTrunc []*types.TagReference
645
645
BranchesTrunc []types.Branch
646
646
// ForkInfo *types.ForkInfo
647
-
HTMLReadme template.HTML
648
-
Raw bool
649
-
EmailToDid map[string]string
650
-
VerifiedCommits commitverify.VerifiedCommits
651
-
Languages []types.RepoLanguageDetails
652
-
Pipelines map[string]models.Pipeline
653
-
NeedsKnotUpgrade bool
647
+
HTMLReadme template.HTML
648
+
Raw bool
649
+
EmailToDidOrHandle map[string]string
650
+
VerifiedCommits commitverify.VerifiedCommits
651
+
Languages []types.RepoLanguageDetails
652
+
Pipelines map[string]models.Pipeline
653
+
NeedsKnotUpgrade bool
654
654
types.RepoIndexResponse
655
655
}
656
656
···
685
685
}
686
686
687
687
type RepoLogParams struct {
688
-
LoggedInUser *oauth.User
689
-
RepoInfo repoinfo.RepoInfo
690
-
TagMap map[string][]string
691
-
Active string
692
-
EmailToDid map[string]string
693
-
VerifiedCommits commitverify.VerifiedCommits
694
-
Pipelines map[string]models.Pipeline
695
-
688
+
LoggedInUser *oauth.User
689
+
RepoInfo repoinfo.RepoInfo
690
+
TagMap map[string][]string
696
691
types.RepoLogResponse
692
+
Active string
693
+
EmailToDidOrHandle map[string]string
694
+
VerifiedCommits commitverify.VerifiedCommits
695
+
Pipelines map[string]models.Pipeline
697
696
}
698
697
699
698
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
···
702
701
}
703
702
704
703
type RepoCommitParams struct {
705
-
LoggedInUser *oauth.User
706
-
RepoInfo repoinfo.RepoInfo
707
-
Active string
708
-
EmailToDid map[string]string
709
-
Pipeline *models.Pipeline
710
-
DiffOpts types.DiffOpts
704
+
LoggedInUser *oauth.User
705
+
RepoInfo repoinfo.RepoInfo
706
+
Active string
707
+
EmailToDidOrHandle map[string]string
708
+
Pipeline *models.Pipeline
709
+
DiffOpts types.DiffOpts
711
710
712
711
// singular because it's always going to be just one
713
712
VerifiedCommit commitverify.VerifiedCommits
···
739
738
func (r RepoTreeParams) TreeStats() RepoTreeStats {
740
739
numFolders, numFiles := 0, 0
741
740
for _, f := range r.Files {
742
-
if !f.IsFile() {
741
+
if !f.IsFile {
743
742
numFolders += 1
744
-
} else if f.IsFile() {
743
+
} else if f.IsFile {
745
744
numFiles += 1
746
745
}
747
746
}
···
812
811
}
813
812
814
813
type RepoBlobParams struct {
815
-
LoggedInUser *oauth.User
816
-
RepoInfo repoinfo.RepoInfo
817
-
Active string
818
-
BreadCrumbs [][]string
819
-
BlobView models.BlobView
814
+
LoggedInUser *oauth.User
815
+
RepoInfo repoinfo.RepoInfo
816
+
Active string
817
+
Unsupported bool
818
+
IsImage bool
819
+
IsVideo bool
820
+
ContentSrc string
821
+
BreadCrumbs [][]string
822
+
ShowRendered bool
823
+
RenderToggle bool
824
+
RenderedContents template.HTML
820
825
*tangled.RepoBlob_Output
826
+
// Computed fields for template compatibility
827
+
Contents string
828
+
Lines int
829
+
SizeHint uint64
830
+
IsBinary bool
821
831
}
822
832
823
833
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
824
-
switch params.BlobView.ContentType {
825
-
case models.BlobContentTypeMarkup:
826
-
p.rctx.RepoInfo = params.RepoInfo
834
+
var style *chroma.Style = styles.Get("catpuccin-latte")
835
+
836
+
if params.ShowRendered {
837
+
switch markup.GetFormat(params.Path) {
838
+
case markup.FormatMarkdown:
839
+
p.rctx.RepoInfo = params.RepoInfo
840
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
841
+
htmlString := p.rctx.RenderMarkdown(params.Contents)
842
+
sanitized := p.rctx.SanitizeDefault(htmlString)
843
+
params.RenderedContents = template.HTML(sanitized)
844
+
}
845
+
}
846
+
847
+
c := params.Contents
848
+
formatter := chromahtml.New(
849
+
chromahtml.InlineCode(false),
850
+
chromahtml.WithLineNumbers(true),
851
+
chromahtml.WithLinkableLineNumbers(true, "L"),
852
+
chromahtml.Standalone(false),
853
+
chromahtml.WithClasses(true),
854
+
)
855
+
856
+
lexer := lexers.Get(filepath.Base(params.Path))
857
+
if lexer == nil {
858
+
lexer = lexers.Fallback
859
+
}
860
+
861
+
iterator, err := lexer.Tokenise(nil, c)
862
+
if err != nil {
863
+
return fmt.Errorf("chroma tokenize: %w", err)
864
+
}
865
+
866
+
var code bytes.Buffer
867
+
err = formatter.Format(&code, style, iterator)
868
+
if err != nil {
869
+
return fmt.Errorf("chroma format: %w", err)
827
870
}
828
871
872
+
params.Contents = code.String()
829
873
params.Active = "overview"
830
874
return p.executeRepo("repo/blob", w, params)
831
875
}
···
911
955
LabelDefs map[string]*models.LabelDefinition
912
956
Page pagination.Page
913
957
FilteringByOpen bool
914
-
FilterQuery string
915
958
}
916
959
917
960
func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
···
928
971
LabelDefs map[string]*models.LabelDefinition
929
972
930
973
OrderedReactionKinds []models.ReactionKind
931
-
Reactions map[models.ReactionKind]models.ReactionDisplayData
974
+
Reactions map[models.ReactionKind]int
932
975
UserReacted map[models.ReactionKind]bool
933
976
}
934
977
···
953
996
ThreadAt syntax.ATURI
954
997
Kind models.ReactionKind
955
998
Count int
956
-
Users []string
957
999
IsReacted bool
958
1000
}
959
1001
···
1042
1084
Pulls []*models.Pull
1043
1085
Active string
1044
1086
FilteringBy models.PullState
1045
-
FilterQuery string
1046
1087
Stacks map[string]models.Stack
1047
1088
Pipelines map[string]models.Pipeline
1048
-
LabelDefs map[string]*models.LabelDefinition
1049
1089
}
1050
1090
1051
1091
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1072
1112
}
1073
1113
1074
1114
type RepoSinglePullParams struct {
1075
-
LoggedInUser *oauth.User
1076
-
RepoInfo repoinfo.RepoInfo
1077
-
Active string
1078
-
Pull *models.Pull
1079
-
Stack models.Stack
1080
-
AbandonedPulls []*models.Pull
1081
-
BranchDeleteStatus *models.BranchDeleteStatus
1082
-
MergeCheck types.MergeCheckResponse
1083
-
ResubmitCheck ResubmitResult
1084
-
Pipelines map[string]models.Pipeline
1115
+
LoggedInUser *oauth.User
1116
+
RepoInfo repoinfo.RepoInfo
1117
+
Active string
1118
+
Pull *models.Pull
1119
+
Stack models.Stack
1120
+
AbandonedPulls []*models.Pull
1121
+
MergeCheck types.MergeCheckResponse
1122
+
ResubmitCheck ResubmitResult
1123
+
Pipelines map[string]models.Pipeline
1085
1124
1086
1125
OrderedReactionKinds []models.ReactionKind
1087
-
Reactions map[models.ReactionKind]models.ReactionDisplayData
1126
+
Reactions map[models.ReactionKind]int
1088
1127
UserReacted map[models.ReactionKind]bool
1089
-
1090
-
LabelDefs map[string]*models.LabelDefinition
1091
1128
}
1092
1129
1093
1130
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
1177
1214
}
1178
1215
1179
1216
type PullActionsParams struct {
1180
-
LoggedInUser *oauth.User
1181
-
RepoInfo repoinfo.RepoInfo
1182
-
Pull *models.Pull
1183
-
RoundNumber int
1184
-
MergeCheck types.MergeCheckResponse
1185
-
ResubmitCheck ResubmitResult
1186
-
BranchDeleteStatus *models.BranchDeleteStatus
1187
-
Stack models.Stack
1217
+
LoggedInUser *oauth.User
1218
+
RepoInfo repoinfo.RepoInfo
1219
+
Pull *models.Pull
1220
+
RoundNumber int
1221
+
MergeCheck types.MergeCheckResponse
1222
+
ResubmitCheck ResubmitResult
1223
+
Stack models.Stack
1188
1224
}
1189
1225
1190
1226
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
···
1300
1336
Name string
1301
1337
Command string
1302
1338
Collapsed bool
1303
-
StartTime time.Time
1304
1339
}
1305
1340
1306
1341
func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
1307
1342
return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
1308
-
}
1309
-
1310
-
type LogBlockEndParams struct {
1311
-
Id int
1312
-
StartTime time.Time
1313
-
EndTime time.Time
1314
-
}
1315
-
1316
-
func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error {
1317
-
return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params)
1318
1343
}
1319
1344
1320
1345
type LogLineParams struct {
···
1382
1407
}
1383
1408
1384
1409
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1410
+
var style *chroma.Style = styles.Get("catpuccin-latte")
1411
+
1412
+
if params.ShowRendered {
1413
+
switch markup.GetFormat(params.String.Filename) {
1414
+
case markup.FormatMarkdown:
1415
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
1416
+
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
1417
+
sanitized := p.rctx.SanitizeDefault(htmlString)
1418
+
params.RenderedContents = template.HTML(sanitized)
1419
+
}
1420
+
}
1421
+
1422
+
c := params.String.Contents
1423
+
formatter := chromahtml.New(
1424
+
chromahtml.InlineCode(false),
1425
+
chromahtml.WithLineNumbers(true),
1426
+
chromahtml.WithLinkableLineNumbers(true, "L"),
1427
+
chromahtml.Standalone(false),
1428
+
chromahtml.WithClasses(true),
1429
+
)
1430
+
1431
+
lexer := lexers.Get(filepath.Base(params.String.Filename))
1432
+
if lexer == nil {
1433
+
lexer = lexers.Fallback
1434
+
}
1435
+
1436
+
iterator, err := lexer.Tokenise(nil, c)
1437
+
if err != nil {
1438
+
return fmt.Errorf("chroma tokenize: %w", err)
1439
+
}
1440
+
1441
+
var code bytes.Buffer
1442
+
err = formatter.Format(&code, style, iterator)
1443
+
if err != nil {
1444
+
return fmt.Errorf("chroma format: %w", err)
1445
+
}
1446
+
1447
+
params.String.Contents = code.String()
1385
1448
return p.execute("strings/string", w, params)
1386
1449
}
1387
1450
···
1394
1457
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
1395
1458
}
1396
1459
1397
-
sub, err := fs.Sub(p.embedFS, "static")
1460
+
sub, err := fs.Sub(Files, "static")
1398
1461
if err != nil {
1399
1462
p.logger.Error("no static dir found? that's crazy", "err", err)
1400
1463
panic(err)
···
1417
1480
})
1418
1481
}
1419
1482
1420
-
func (p *Pages) CssContentHash() string {
1421
-
cssFile, err := p.embedFS.Open("static/tw.css")
1483
+
func CssContentHash() string {
1484
+
cssFile, err := Files.Open("static/tw.css")
1422
1485
if err != nil {
1423
1486
slog.Debug("Error opening CSS file", "err", err)
1424
1487
return ""
+7
-7
appview/pages/repoinfo/repoinfo.go
+7
-7
appview/pages/repoinfo/repoinfo.go
···
1
1
package repoinfo
2
2
3
3
import (
4
+
"fmt"
4
5
"path"
5
6
"slices"
7
+
"strings"
6
8
7
9
"github.com/bluesky-social/indigo/atproto/syntax"
8
10
"tangled.org/core/appview/models"
9
11
"tangled.org/core/appview/state/userutil"
10
12
)
11
13
12
-
func (r RepoInfo) Owner() string {
14
+
func (r RepoInfo) OwnerWithAt() string {
13
15
if r.OwnerHandle != "" {
14
-
return r.OwnerHandle
16
+
return fmt.Sprintf("@%s", r.OwnerHandle)
15
17
} else {
16
18
return r.OwnerDid
17
19
}
18
20
}
19
21
20
22
func (r RepoInfo) FullName() string {
21
-
return path.Join(r.Owner(), r.Name)
23
+
return path.Join(r.OwnerWithAt(), r.Name)
22
24
}
23
25
24
26
func (r RepoInfo) OwnerWithoutAt() string {
25
-
if r.OwnerHandle != "" {
26
-
return r.OwnerHandle
27
+
if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok {
28
+
return after
27
29
} else {
28
30
return userutil.FlattenDid(r.OwnerDid)
29
31
}
···
54
56
OwnerDid string
55
57
OwnerHandle string
56
58
Description string
57
-
Website string
58
-
Topics []string
59
59
Knot string
60
60
Spindle string
61
61
RepoAt syntax.ATURI
+54
-82
appview/pages/templates/fragments/dolly/logo.html
+54
-82
appview/pages/templates/fragments/dolly/logo.html
···
1
1
{{ define "fragments/dolly/logo" }}
2
-
<svg
3
-
version="1.1"
4
-
id="svg1"
5
-
class="{{ . }}"
6
-
width="25"
7
-
height="25"
8
-
viewBox="0 0 25 25"
9
-
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
10
-
inkscape:export-filename="tangled_logotype_black_on_trans.svg"
11
-
inkscape:export-xdpi="96"
12
-
inkscape:export-ydpi="96"
13
-
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
14
-
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
15
-
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
16
-
xmlns="http://www.w3.org/2000/svg"
17
-
xmlns:svg="http://www.w3.org/2000/svg"
18
-
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
19
-
xmlns:cc="http://creativecommons.org/ns#">
20
-
<sodipodi:namedview
21
-
id="namedview1"
22
-
pagecolor="#ffffff"
23
-
bordercolor="#000000"
24
-
borderopacity="0.25"
25
-
inkscape:showpageshadow="2"
26
-
inkscape:pageopacity="0.0"
27
-
inkscape:pagecheckerboard="true"
28
-
inkscape:deskcolor="#d5d5d5"
29
-
inkscape:zoom="45.254834"
30
-
inkscape:cx="3.1377863"
31
-
inkscape:cy="8.9382717"
32
-
inkscape:window-width="3840"
33
-
inkscape:window-height="2160"
34
-
inkscape:window-x="0"
35
-
inkscape:window-y="0"
36
-
inkscape:window-maximized="0"
37
-
inkscape:current-layer="g1"
38
-
borderlayer="true">
39
-
<inkscape:page
40
-
x="0"
41
-
y="0"
42
-
width="25"
43
-
height="25"
44
-
id="page2"
45
-
margin="0"
46
-
bleed="0" />
47
-
</sodipodi:namedview>
48
-
<g
49
-
inkscape:groupmode="layer"
50
-
inkscape:label="Image"
51
-
id="g1"
52
-
transform="translate(-0.42924038,-0.87777209)">
53
-
<path
54
-
fill="currentColor"
55
-
style="stroke-width:0.111183;"
56
-
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z"
57
-
id="path4"
58
-
sodipodi:nodetypes="sccccccccccccccccccsscccccccccsccccccccccccccccccccccc" />
59
-
</g>
60
-
<metadata
61
-
id="metadata1">
62
-
<rdf:RDF>
63
-
<cc:Work
64
-
rdf:about="">
65
-
<cc:license
66
-
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
67
-
</cc:Work>
68
-
<cc:License
69
-
rdf:about="http://creativecommons.org/licenses/by/4.0/">
70
-
<cc:permits
71
-
rdf:resource="http://creativecommons.org/ns#Reproduction" />
72
-
<cc:permits
73
-
rdf:resource="http://creativecommons.org/ns#Distribution" />
74
-
<cc:requires
75
-
rdf:resource="http://creativecommons.org/ns#Notice" />
76
-
<cc:requires
77
-
rdf:resource="http://creativecommons.org/ns#Attribution" />
78
-
<cc:permits
79
-
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
80
-
</cc:License>
81
-
</rdf:RDF>
82
-
</metadata>
83
-
</svg>
2
+
<svg
3
+
version="1.1"
4
+
id="svg1"
5
+
class="{{.}}"
6
+
width="25"
7
+
height="25"
8
+
viewBox="0 0 25 25"
9
+
sodipodi:docname="tangled_dolly_face_only.png"
10
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
11
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
12
+
xmlns:xlink="http://www.w3.org/1999/xlink"
13
+
xmlns="http://www.w3.org/2000/svg"
14
+
xmlns:svg="http://www.w3.org/2000/svg">
15
+
<title>Dolly</title>
16
+
<defs
17
+
id="defs1" />
18
+
<sodipodi:namedview
19
+
id="namedview1"
20
+
pagecolor="#ffffff"
21
+
bordercolor="#000000"
22
+
borderopacity="0.25"
23
+
inkscape:showpageshadow="2"
24
+
inkscape:pageopacity="0.0"
25
+
inkscape:pagecheckerboard="true"
26
+
inkscape:deskcolor="#d5d5d5">
27
+
<inkscape:page
28
+
x="0"
29
+
y="0"
30
+
width="25"
31
+
height="25"
32
+
id="page2"
33
+
margin="0"
34
+
bleed="0" />
35
+
</sodipodi:namedview>
36
+
<g
37
+
inkscape:groupmode="layer"
38
+
inkscape:label="Image"
39
+
id="g1">
40
+
<image
41
+
width="252.48"
42
+
height="248.96001"
43
+
preserveAspectRatio="none"
44
+
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9 kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7 vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0 M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0 AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39 NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz 3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS vkUNCjRNU+qvG2ASmKpvJ+rb9n3T9e0EvTCx3Pfr/o8ttf9M/fVS/bOL9bbU97HlvrcT7d/bNE1/ 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 0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/ //6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj 4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC /wcO9A7eMaXQEQAAAABJRU5ErkJggg== "
45
+
id="image1"
46
+
x="-233.6257"
47
+
y="10.383364"
48
+
style="display:none" />
49
+
<path
50
+
fill="currentColor"
51
+
style="stroke-width:0.111183"
52
+
d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z"
53
+
id="path4" />
54
+
</g>
55
+
</svg>
84
56
{{ end }}
+22
-60
appview/pages/templates/fragments/dolly/silhouette.html
+22
-60
appview/pages/templates/fragments/dolly/silhouette.html
···
2
2
<svg
3
3
version="1.1"
4
4
id="svg1"
5
-
width="25"
6
-
height="25"
5
+
width="32"
6
+
height="32"
7
7
viewBox="0 0 25 25"
8
-
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
9
-
inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg"
10
-
inkscape:export-xdpi="96"
11
-
inkscape:export-ydpi="96"
12
-
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
8
+
sodipodi:docname="tangled_dolly_silhouette.png"
13
9
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
14
10
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
15
11
xmlns="http://www.w3.org/2000/svg"
16
-
xmlns:svg="http://www.w3.org/2000/svg"
17
-
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
18
-
xmlns:cc="http://creativecommons.org/ns#">
19
-
<style>
20
-
.dolly {
21
-
color: #000000;
22
-
}
12
+
xmlns:svg="http://www.w3.org/2000/svg">
13
+
<style>
14
+
.dolly {
15
+
color: #000000;
16
+
}
23
17
24
-
@media (prefers-color-scheme: dark) {
25
-
.dolly {
26
-
color: #ffffff;
27
-
}
28
-
}
29
-
</style>
18
+
@media (prefers-color-scheme: dark) {
19
+
.dolly {
20
+
color: #ffffff;
21
+
}
22
+
}
23
+
</style>
24
+
<title>Dolly</title>
25
+
<defs
26
+
id="defs1" />
30
27
<sodipodi:namedview
31
28
id="namedview1"
32
29
pagecolor="#ffffff"
···
35
32
inkscape:showpageshadow="2"
36
33
inkscape:pageopacity="0.0"
37
34
inkscape:pagecheckerboard="true"
38
-
inkscape:deskcolor="#d5d5d5"
39
-
inkscape:zoom="64"
40
-
inkscape:cx="4.96875"
41
-
inkscape:cy="13.429688"
42
-
inkscape:window-width="3840"
43
-
inkscape:window-height="2160"
44
-
inkscape:window-x="0"
45
-
inkscape:window-y="0"
46
-
inkscape:window-maximized="0"
47
-
inkscape:current-layer="g1"
48
-
borderlayer="true">
35
+
inkscape:deskcolor="#d1d1d1">
49
36
<inkscape:page
50
37
x="0"
51
38
y="0"
···
58
45
<g
59
46
inkscape:groupmode="layer"
60
47
inkscape:label="Image"
61
-
id="g1"
62
-
transform="translate(-0.42924038,-0.87777209)">
48
+
id="g1">
63
49
<path
64
50
class="dolly"
65
51
fill="currentColor"
66
-
style="stroke-width:0.111183"
67
-
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z"
68
-
id="path7"
69
-
sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" />
52
+
style="stroke-width:1.12248"
53
+
d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"
54
+
id="path1" />
70
55
</g>
71
-
<metadata
72
-
id="metadata1">
73
-
<rdf:RDF>
74
-
<cc:Work
75
-
rdf:about="">
76
-
<cc:license
77
-
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
78
-
</cc:Work>
79
-
<cc:License
80
-
rdf:about="http://creativecommons.org/licenses/by/4.0/">
81
-
<cc:permits
82
-
rdf:resource="http://creativecommons.org/ns#Reproduction" />
83
-
<cc:permits
84
-
rdf:resource="http://creativecommons.org/ns#Distribution" />
85
-
<cc:requires
86
-
rdf:resource="http://creativecommons.org/ns#Notice" />
87
-
<cc:requires
88
-
rdf:resource="http://creativecommons.org/ns#Attribution" />
89
-
<cc:permits
90
-
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
91
-
</cc:License>
92
-
</rdf:RDF>
93
-
</metadata>
94
56
</svg>
95
57
{{ end }}
-25
appview/pages/templates/fragments/tabSelector.html
-25
appview/pages/templates/fragments/tabSelector.html
···
1
-
{{ define "fragments/tabSelector" }}
2
-
{{ $name := .Name }}
3
-
{{ $all := .Values }}
4
-
{{ $active := .Active }}
5
-
<div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
6
-
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
7
-
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
8
-
{{ range $index, $value := $all }}
9
-
{{ $isActive := eq $value.Key $active }}
10
-
<a href="?{{ $name }}={{ $value.Key }}"
11
-
class="p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
12
-
{{ if $value.Icon }}
13
-
{{ i $value.Icon "size-4" }}
14
-
{{ end }}
15
-
16
-
{{ with $value.Meta }}
17
-
{{ . }}
18
-
{{ end }}
19
-
20
-
{{ $value.Value }}
21
-
</a>
22
-
{{ end }}
23
-
</div>
24
-
{{ end }}
25
-
-36
appview/pages/templates/fragments/workflow-timers.html
-36
appview/pages/templates/fragments/workflow-timers.html
···
1
-
{{ define "fragments/workflow-timers" }}
2
-
<script>
3
-
function formatElapsed(seconds) {
4
-
if (seconds < 1) return '0s';
5
-
if (seconds < 60) return `${seconds}s`;
6
-
const minutes = Math.floor(seconds / 60);
7
-
const secs = seconds % 60;
8
-
if (seconds < 3600) return `${minutes}m ${secs}s`;
9
-
const hours = Math.floor(seconds / 3600);
10
-
const mins = Math.floor((seconds % 3600) / 60);
11
-
return `${hours}h ${mins}m`;
12
-
}
13
-
14
-
function updateTimers() {
15
-
const now = Math.floor(Date.now() / 1000);
16
-
17
-
document.querySelectorAll('[data-timer]').forEach(el => {
18
-
const startTime = parseInt(el.dataset.start);
19
-
const endTime = el.dataset.end ? parseInt(el.dataset.end) : null;
20
-
21
-
if (endTime) {
22
-
// Step is complete, show final time
23
-
const elapsed = endTime - startTime;
24
-
el.textContent = formatElapsed(elapsed);
25
-
} else {
26
-
// Step is running, update live
27
-
const elapsed = now - startTime;
28
-
el.textContent = formatElapsed(elapsed);
29
-
}
30
-
});
31
-
}
32
-
33
-
setInterval(updateTimers, 1000);
34
-
updateTimers();
35
-
</script>
36
-
{{ end }}
-167
appview/pages/templates/goodfirstissues/index.html
-167
appview/pages/templates/goodfirstissues/index.html
···
1
-
{{ define "title" }}good first issues{{ end }}
2
-
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="good first issues · tangled" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.org/goodfirstissues" />
7
-
<meta property="og:description" content="Find good first issues to contribute to open source projects" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-10">
12
-
<header class="col-span-full md:col-span-10 px-6 py-2 text-center flex flex-col items-center justify-center py-8">
13
-
<h1 class="scale-150 dark:text-white mb-4">
14
-
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
15
-
</h1>
16
-
<p class="text-gray-600 dark:text-gray-400 mb-2">
17
-
Find beginner-friendly issues across all repositories to get started with open source contributions.
18
-
</p>
19
-
</header>
20
-
21
-
<div class="col-span-full md:col-span-10 space-y-6">
22
-
{{ if eq (len .RepoGroups) 0 }}
23
-
<div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
24
-
<div class="text-center py-16">
25
-
<div class="text-gray-500 dark:text-gray-400 mb-4">
26
-
{{ i "circle-dot" "w-16 h-16 mx-auto" }}
27
-
</div>
28
-
<h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No good first issues available</h3>
29
-
<p class="text-gray-600 dark:text-gray-400 mb-3 max-w-md mx-auto">
30
-
There are currently no open issues labeled as "good-first-issue" across all repositories.
31
-
</p>
32
-
<p class="text-gray-500 dark:text-gray-500 text-sm max-w-md mx-auto">
33
-
Repository maintainers can add the "good-first-issue" label to beginner-friendly issues to help newcomers get started.
34
-
</p>
35
-
</div>
36
-
</div>
37
-
{{ else }}
38
-
{{ range .RepoGroups }}
39
-
<div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800">
40
-
<div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap">
41
-
<div class="font-medium dark:text-white flex items-center justify-between">
42
-
<div class="flex items-center min-w-0 flex-1 mr-2">
43
-
{{ if .Repo.Source }}
44
-
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
45
-
{{ else }}
46
-
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
47
-
{{ end }}
48
-
{{ $repoOwner := resolve .Repo.Did }}
49
-
<a href="/{{ $repoOwner }}/{{ .Repo.Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a>
50
-
</div>
51
-
</div>
52
-
53
-
54
-
{{ if .Repo.RepoStats }}
55
-
<div class="text-gray-400 text-sm font-mono inline-flex gap-4">
56
-
{{ with .Repo.RepoStats.Language }}
57
-
<div class="flex gap-2 items-center text-sm">
58
-
{{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }}
59
-
<span>{{ . }}</span>
60
-
</div>
61
-
{{ end }}
62
-
{{ with .Repo.RepoStats.StarCount }}
63
-
<div class="flex gap-1 items-center text-sm">
64
-
{{ i "star" "w-3 h-3 fill-current" }}
65
-
<span>{{ . }}</span>
66
-
</div>
67
-
{{ end }}
68
-
{{ with .Repo.RepoStats.IssueCount.Open }}
69
-
<div class="flex gap-1 items-center text-sm">
70
-
{{ i "circle-dot" "w-3 h-3" }}
71
-
<span>{{ . }}</span>
72
-
</div>
73
-
{{ end }}
74
-
{{ with .Repo.RepoStats.PullCount.Open }}
75
-
<div class="flex gap-1 items-center text-sm">
76
-
{{ i "git-pull-request" "w-3 h-3" }}
77
-
<span>{{ . }}</span>
78
-
</div>
79
-
{{ end }}
80
-
</div>
81
-
{{ end }}
82
-
</div>
83
-
84
-
{{ with .Repo.Description }}
85
-
<div class="pl-6 pb-2 text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
86
-
{{ . | description }}
87
-
</div>
88
-
{{ end }}
89
-
90
-
{{ if gt (len .Issues) 0 }}
91
-
<div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
92
-
{{ range .Issues }}
93
-
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
94
-
<div class="py-2 px-6">
95
-
<div class="flex-grow min-w-0 w-full">
96
-
<div class="flex text-sm items-center justify-between w-full">
97
-
<div class="flex items-center gap-2 min-w-0 flex-1 pr-2">
98
-
<span class="truncate text-sm text-gray-800 dark:text-gray-200">
99
-
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
100
-
{{ .Title | description }}
101
-
</span>
102
-
</div>
103
-
<div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400">
104
-
<span>
105
-
<div class="inline-flex items-center gap-1">
106
-
{{ i "message-square" "w-3 h-3" }}
107
-
{{ len .Comments }}
108
-
</div>
109
-
</span>
110
-
<span class="before:content-['·'] before:select-none"></span>
111
-
<span class="text-sm">
112
-
{{ template "repo/fragments/shortTimeAgo" .Created }}
113
-
</span>
114
-
<div class="hidden md:inline-flex md:gap-1">
115
-
{{ $labelState := .Labels }}
116
-
{{ range $k, $d := $.LabelDefs }}
117
-
{{ range $v, $s := $labelState.GetValSet $d.AtUri.String }}
118
-
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
119
-
{{ end }}
120
-
{{ end }}
121
-
</div>
122
-
</div>
123
-
</div>
124
-
</div>
125
-
</div>
126
-
</a>
127
-
{{ end }}
128
-
</div>
129
-
{{ end }}
130
-
</div>
131
-
{{ end }}
132
-
133
-
{{ if or (gt .Page.Offset 0) (eq (len .RepoGroups) .Page.Limit) }}
134
-
<div class="flex justify-center mt-8">
135
-
<div class="flex gap-2">
136
-
{{ if gt .Page.Offset 0 }}
137
-
{{ $prev := .Page.Previous }}
138
-
<a
139
-
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
140
-
hx-boost="true"
141
-
href="/goodfirstissues?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
142
-
>
143
-
{{ i "chevron-left" "w-4 h-4" }}
144
-
previous
145
-
</a>
146
-
{{ else }}
147
-
<div></div>
148
-
{{ end }}
149
-
150
-
{{ if eq (len .RepoGroups) .Page.Limit }}
151
-
{{ $next := .Page.Next }}
152
-
<a
153
-
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
154
-
hx-boost="true"
155
-
href="/goodfirstissues?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
156
-
>
157
-
next
158
-
{{ i "chevron-right" "w-4 h-4" }}
159
-
</a>
160
-
{{ end }}
161
-
</div>
162
-
</div>
163
-
{{ end }}
164
-
{{ end }}
165
-
</div>
166
-
</div>
167
-
{{ end }}
+9
-17
appview/pages/templates/knots/fragments/addMemberModal.html
+9
-17
appview/pages/templates/knots/fragments/addMemberModal.html
···
13
13
<div
14
14
id="add-member-{{ .Id }}"
15
15
popover
16
-
class="
17
-
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
18
-
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
16
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
19
17
{{ block "addKnotMemberPopover" . }} {{ end }}
20
18
</div>
21
19
{{ end }}
···
31
29
ADD MEMBER
32
30
</label>
33
31
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
34
-
<actor-typeahead>
35
-
<input
36
-
autocapitalize="none"
37
-
autocorrect="off"
38
-
autocomplete="off"
39
-
type="text"
40
-
id="member-did-{{ .Id }}"
41
-
name="member"
42
-
required
43
-
placeholder="user.tngl.sh"
44
-
class="w-full"
45
-
/>
46
-
</actor-typeahead>
32
+
<input
33
+
type="text"
34
+
id="member-did-{{ .Id }}"
35
+
name="member"
36
+
required
37
+
placeholder="@foo.bsky.social"
38
+
/>
47
39
<div class="flex gap-2 pt-2">
48
40
<button
49
41
type="button"
···
62
54
</div>
63
55
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
64
56
</form>
65
-
{{ end }}
57
+
{{ end }}
+1
-1
appview/pages/templates/labels/fragments/label.html
+1
-1
appview/pages/templates/labels/fragments/label.html
···
2
2
{{ $d := .def }}
3
3
{{ $v := .val }}
4
4
{{ $withPrefix := .withPrefix }}
5
-
<span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
5
+
<span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
6
6
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
7
7
8
8
{{ $lhs := printf "%s" $d.Name }}
+12
-17
appview/pages/templates/layouts/base.html
+12
-17
appview/pages/templates/layouts/base.html
···
9
9
10
10
<script defer src="/static/htmx.min.js"></script>
11
11
<script defer src="/static/htmx-ext-ws.min.js"></script>
12
-
<script defer src="/static/actor-typeahead.js" type="module"></script>
13
12
14
13
<!-- preconnect to image cdn -->
15
14
<link rel="preconnect" href="https://avatar.tangled.sh" />
16
15
<link rel="preconnect" href="https://camo.tangled.sh" />
17
-
18
-
<!-- pwa manifest -->
19
-
<link rel="manifest" href="/pwa-manifest.json" />
20
16
21
17
<!-- preload main font -->
22
18
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
···
25
21
<title>{{ block "title" . }}{{ end }} · tangled</title>
26
22
{{ block "extrameta" . }}{{ end }}
27
23
</head>
28
-
<body class="min-h-screen flex flex-col 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] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"
25
+
style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);">
29
26
{{ block "topbarLayout" . }}
30
-
<header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
27
+
<header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
31
28
32
29
{{ if .LoggedInUser }}
33
30
<div id="upgrade-banner"
···
41
38
{{ end }}
42
39
43
40
{{ block "mainLayout" . }}
44
-
<div class="flex-grow">
45
-
<div class="max-w-screen-lg mx-auto flex flex-col gap-4">
46
-
{{ block "contentLayout" . }}
47
-
<main>
41
+
<div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4">
42
+
{{ block "contentLayout" . }}
43
+
<main class="col-span-1 md:col-span-8">
48
44
{{ block "content" . }}{{ end }}
49
45
</main>
50
-
{{ end }}
51
-
52
-
{{ block "contentAfterLayout" . }}
53
-
<main>
46
+
{{ end }}
47
+
48
+
{{ block "contentAfterLayout" . }}
49
+
<main class="col-span-1 md:col-span-8">
54
50
{{ block "contentAfter" . }}{{ end }}
55
51
</main>
56
-
{{ end }}
57
-
</div>
52
+
{{ end }}
58
53
</div>
59
54
{{ end }}
60
55
61
56
{{ block "footerLayout" . }}
62
-
<footer class="mt-12">
57
+
<footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12">
63
58
{{ template "layouts/fragments/footer" . }}
64
59
</footer>
65
60
{{ end }}
+11
-7
appview/pages/templates/layouts/fragments/topbar.html
+11
-7
appview/pages/templates/layouts/fragments/topbar.html
···
1
1
{{ define "layouts/fragments/topbar" }}
2
-
<nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800">
2
+
<nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
3
<div class="flex justify-between p-0 items-center">
4
4
<div id="left-items">
5
5
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
···
15
15
{{ with .LoggedInUser }}
16
16
{{ block "newButton" . }} {{ end }}
17
17
{{ template "notifications/fragments/bell" }}
18
-
{{ block "profileDropdown" . }} {{ end }}
18
+
{{ block "dropDown" . }} {{ end }}
19
19
{{ else }}
20
20
<a href="/login">login</a>
21
21
<span class="text-gray-500 dark:text-gray-400">or</span>
···
33
33
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
34
34
{{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span>
35
35
</summary>
36
-
<div class="absolute flex flex-col right-0 mt-3 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
36
+
<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">
37
37
<a href="/repo/new" class="flex items-center gap-2">
38
38
{{ i "book-plus" "w-4 h-4" }}
39
39
new repository
···
46
46
</details>
47
47
{{ end }}
48
48
49
-
{{ define "profileDropdown" }}
49
+
{{ define "dropDown" }}
50
50
<details class="relative inline-block text-left nav-dropdown">
51
-
<summary class="cursor-pointer list-none flex items-center gap-1">
52
-
{{ $user := .Did }}
51
+
<summary
52
+
class="cursor-pointer list-none flex items-center gap-1"
53
+
>
54
+
{{ $user := didOrHandle .Did .Handle }}
53
55
<img
54
56
src="{{ tinyAvatar $user }}"
55
57
alt=""
···
57
59
/>
58
60
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
59
61
</summary>
60
-
<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">
62
+
<div
63
+
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"
64
+
>
61
65
<a href="/{{ $user }}">profile</a>
62
66
<a href="/{{ $user }}?tab=repos">repositories</a>
63
67
<a href="/{{ $user }}?tab=strings">strings</a>
-9
appview/pages/templates/layouts/profilebase.html
-9
appview/pages/templates/layouts/profilebase.html
···
1
1
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
2
3
3
{{ define "extrameta" }}
4
-
{{ $avatarUrl := fullAvatar .Card.UserHandle }}
5
4
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
6
5
<meta property="og:type" content="profile" />
7
6
<meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" />
8
7
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
9
-
<meta property="og:image" content="{{ $avatarUrl }}" />
10
-
<meta property="og:image:width" content="512" />
11
-
<meta property="og:image:height" content="512" />
12
-
13
-
<meta name="twitter:card" content="summary" />
14
-
<meta name="twitter:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
15
-
<meta name="twitter:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
16
-
<meta name="twitter:image" content="{{ $avatarUrl }}" />
17
8
{{ end }}
18
9
19
10
{{ define "content" }}
+25
-53
appview/pages/templates/layouts/repobase.html
+25
-53
appview/pages/templates/layouts/repobase.html
···
1
1
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<section id="repo-header" class="mb-4 p-2 dark:text-white">
5
-
<div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between">
6
-
<!-- left items -->
7
-
<div class="flex flex-col gap-2">
8
-
<!-- repo owner / repo name -->
9
-
<div class="flex items-center gap-2 flex-wrap">
10
-
{{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }}
11
-
<span class="select-none">/</span>
12
-
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
13
-
</div>
14
-
15
-
{{ if .RepoInfo.Source }}
16
-
{{ $sourceOwner := resolve .RepoInfo.Source.Did }}
17
-
<div class="flex items-center gap-1 text-sm flex-wrap">
18
-
{{ i "git-fork" "w-3 h-3 shrink-0" }}
19
-
<span>forked from</span>
20
-
<a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">
21
-
{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}
22
-
</a>
23
-
</div>
24
-
{{ end }}
25
-
26
-
<span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300">
27
-
{{ if .RepoInfo.Description }}
28
-
{{ .RepoInfo.Description | description }}
29
-
{{ else }}
30
-
<span class="italic">this repo has no description</span>
31
-
{{ end }}
32
-
33
-
{{ with .RepoInfo.Website }}
34
-
<span class="flex items-center gap-1">
35
-
<span class="flex-shrink-0">{{ i "globe" "size-4" }}</span>
36
-
<a href="{{ . }}">{{ . | trimUriScheme }}</a>
37
-
</span>
38
-
{{ end }}
39
-
40
-
{{ if .RepoInfo.Topics }}
41
-
<div class="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300">
42
-
{{ range .RepoInfo.Topics }}
43
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm">{{ . }}</span>
44
-
{{ end }}
45
-
</div>
46
-
{{ end }}
47
-
48
-
</span>
4
+
<section id="repo-header" class="mb-4 py-2 px-6 dark:text-white">
5
+
{{ if .RepoInfo.Source }}
6
+
<p class="text-sm">
7
+
<div class="flex items-center">
8
+
{{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }}
9
+
forked from
10
+
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
11
+
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
12
+
</div>
13
+
</p>
14
+
{{ end }}
15
+
<div class="text-lg flex items-center justify-between">
16
+
<div>
17
+
<a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a>
18
+
<span class="select-none">/</span>
19
+
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
49
20
</div>
50
21
51
-
<div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto">
22
+
<div class="flex items-center gap-2 z-auto">
23
+
<a
24
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
25
+
href="/{{ .RepoInfo.FullName }}/feed.atom"
26
+
>
27
+
{{ i "rss" "size-4" }}
28
+
</a>
52
29
{{ template "repo/fragments/repoStar" .RepoInfo }}
53
30
<a
54
31
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
···
59
36
fork
60
37
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
61
38
</a>
62
-
<a
63
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
64
-
href="/{{ .RepoInfo.FullName }}/feed.atom">
65
-
{{ i "rss" "size-4" }}
66
-
<span class="md:hidden">atom</span>
67
-
</a>
68
39
</div>
69
40
</div>
41
+
{{ template "repo/fragments/repoDescription" . }}
70
42
</section>
71
43
72
44
<section class="w-full flex flex-col" >
···
107
79
</div>
108
80
</nav>
109
81
{{ block "repoContentLayout" . }}
110
-
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white">
82
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
111
83
{{ block "repoContent" . }}{{ end }}
112
84
</section>
113
85
{{ block "repoAfter" . }}{{ end }}
+209
-37
appview/pages/templates/notifications/fragments/item.html
+209
-37
appview/pages/templates/notifications/fragments/item.html
···
1
1
{{define "notifications/fragments/item"}}
2
-
<a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline">
3
-
<div
4
-
class="
5
-
w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors
6
-
{{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}}
7
-
flex gap-2 items-center
8
-
">
9
-
{{ template "notificationIcon" . }}
10
-
<div class="flex-1 w-full flex flex-col gap-1">
11
-
<div class="flex items-center gap-1">
12
-
<span>{{ template "notificationHeader" . }}</span>
13
-
<span class="text-sm text-gray-500 dark:text-gray-400 before:content-['·'] before:select-none">{{ template "repo/fragments/shortTime" .Created }}</span>
14
-
</div>
15
-
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
16
-
</div>
2
+
<div
3
+
class="
4
+
w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors
5
+
{{if not .Read}}bg-blue-50 dark:bg-blue-900/20 border border-blue-500 dark:border-sky-800{{end}}
6
+
flex gap-2 items-center
7
+
"
8
+
>
17
9
10
+
{{ template "notificationIcon" . }}
11
+
<div class="flex-1 w-full flex flex-col gap-1">
12
+
<span>{{ template "notificationHeader" . }}</span>
13
+
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
18
14
</div>
19
-
</a>
15
+
16
+
</div>
20
17
{{end}}
21
18
22
19
{{ define "notificationIcon" }}
23
20
<div class="flex-shrink-0 max-h-full w-16 h-16 relative">
24
21
<img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" />
25
-
<div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-1 flex items-center justify-center z-10">
22
+
<div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10">
26
23
{{ i .Icon "size-3 text-black dark:text-white" }}
27
24
</div>
28
25
</div>
···
40
37
commented on an issue
41
38
{{ else if eq .Type "issue_closed" }}
42
39
closed an issue
43
-
{{ else if eq .Type "issue_reopen" }}
44
-
reopened an issue
45
40
{{ else if eq .Type "pull_created" }}
46
41
created a pull request
47
42
{{ else if eq .Type "pull_commented" }}
···
50
45
merged a pull request
51
46
{{ else if eq .Type "pull_closed" }}
52
47
closed a pull request
53
-
{{ else if eq .Type "pull_reopen" }}
54
-
reopened a pull request
55
48
{{ else if eq .Type "followed" }}
56
49
followed you
57
-
{{ else if eq .Type "user_mentioned" }}
58
-
mentioned you
59
50
{{ else }}
60
51
{{ end }}
61
52
{{ end }}
···
73
64
{{ end }}
74
65
{{ end }}
75
66
76
-
{{ define "notificationUrl" }}
77
-
{{ $url := "" }}
78
-
{{ if eq .Type "repo_starred" }}
79
-
{{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
80
-
{{ else if .Issue }}
81
-
{{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
82
-
{{ else if .Pull }}
83
-
{{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
84
-
{{ else if eq .Type "followed" }}
85
-
{{$url = printf "/%s" (resolve .ActorDid)}}
86
-
{{ else }}
87
-
{{ end }}
67
+
{{define "issueNotification"}}
68
+
{{$url := printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
69
+
<a
70
+
href="{{$url}}"
71
+
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
72
+
>
73
+
<div class="flex items-center justify-between">
74
+
<div class="min-w-0 flex-1">
75
+
<!-- First line: icon + actor action -->
76
+
<div class="flex items-center gap-2 text-gray-900 dark:text-white">
77
+
{{if eq .Type "issue_created"}}
78
+
<span class="text-green-600 dark:text-green-500">
79
+
{{ i "circle-dot" "w-4 h-4" }}
80
+
</span>
81
+
{{else if eq .Type "issue_commented"}}
82
+
<span class="text-gray-500 dark:text-gray-400">
83
+
{{ i "message-circle" "w-4 h-4" }}
84
+
</span>
85
+
{{else if eq .Type "issue_closed"}}
86
+
<span class="text-gray-500 dark:text-gray-400">
87
+
{{ i "ban" "w-4 h-4" }}
88
+
</span>
89
+
{{end}}
90
+
{{template "user/fragments/picHandle" .ActorDid}}
91
+
{{if eq .Type "issue_created"}}
92
+
<span class="text-gray-500 dark:text-gray-400">opened issue</span>
93
+
{{else if eq .Type "issue_commented"}}
94
+
<span class="text-gray-500 dark:text-gray-400">commented on issue</span>
95
+
{{else if eq .Type "issue_closed"}}
96
+
<span class="text-gray-500 dark:text-gray-400">closed issue</span>
97
+
{{end}}
98
+
{{if not .Read}}
99
+
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
100
+
{{end}}
101
+
</div>
102
+
103
+
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1">
104
+
<span class="text-gray-500 dark:text-gray-400">#{{.Issue.IssueId}}</span>
105
+
<span class="text-gray-900 dark:text-white truncate">{{.Issue.Title}}</span>
106
+
<span>on</span>
107
+
<span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
108
+
</div>
109
+
</div>
110
+
111
+
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
112
+
{{ template "repo/fragments/time" .Created }}
113
+
</div>
114
+
</div>
115
+
</a>
116
+
{{end}}
88
117
89
-
{{ $url }}
90
-
{{ end }}
118
+
{{define "pullNotification"}}
119
+
{{$url := printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
120
+
<a
121
+
href="{{$url}}"
122
+
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
123
+
>
124
+
<div class="flex items-center justify-between">
125
+
<div class="min-w-0 flex-1">
126
+
<div class="flex items-center gap-2 text-gray-900 dark:text-white">
127
+
{{if eq .Type "pull_created"}}
128
+
<span class="text-green-600 dark:text-green-500">
129
+
{{ i "git-pull-request-create" "w-4 h-4" }}
130
+
</span>
131
+
{{else if eq .Type "pull_commented"}}
132
+
<span class="text-gray-500 dark:text-gray-400">
133
+
{{ i "message-circle" "w-4 h-4" }}
134
+
</span>
135
+
{{else if eq .Type "pull_merged"}}
136
+
<span class="text-purple-600 dark:text-purple-500">
137
+
{{ i "git-merge" "w-4 h-4" }}
138
+
</span>
139
+
{{else if eq .Type "pull_closed"}}
140
+
<span class="text-red-600 dark:text-red-500">
141
+
{{ i "git-pull-request-closed" "w-4 h-4" }}
142
+
</span>
143
+
{{end}}
144
+
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
145
+
{{if eq .Type "pull_created"}}
146
+
<span class="text-gray-500 dark:text-gray-400">opened pull request</span>
147
+
{{else if eq .Type "pull_commented"}}
148
+
<span class="text-gray-500 dark:text-gray-400">commented on pull request</span>
149
+
{{else if eq .Type "pull_merged"}}
150
+
<span class="text-gray-500 dark:text-gray-400">merged pull request</span>
151
+
{{else if eq .Type "pull_closed"}}
152
+
<span class="text-gray-500 dark:text-gray-400">closed pull request</span>
153
+
{{end}}
154
+
{{if not .Read}}
155
+
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
156
+
{{end}}
157
+
</div>
158
+
159
+
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1">
160
+
<span class="text-gray-500 dark:text-gray-400">#{{.Pull.PullId}}</span>
161
+
<span class="text-gray-900 dark:text-white truncate">{{.Pull.Title}}</span>
162
+
<span>on</span>
163
+
<span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
164
+
</div>
165
+
</div>
166
+
167
+
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
168
+
{{ template "repo/fragments/time" .Created }}
169
+
</div>
170
+
</div>
171
+
</a>
172
+
{{end}}
173
+
174
+
{{define "repoNotification"}}
175
+
{{$url := printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
176
+
<a
177
+
href="{{$url}}"
178
+
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
179
+
>
180
+
<div class="flex items-center justify-between">
181
+
<div class="flex items-center gap-2 min-w-0 flex-1">
182
+
<span class="text-yellow-500 dark:text-yellow-400">
183
+
{{ i "star" "w-4 h-4" }}
184
+
</span>
185
+
186
+
<div class="min-w-0 flex-1">
187
+
<!-- Single line for stars: actor action subject -->
188
+
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
189
+
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
190
+
<span class="text-gray-500 dark:text-gray-400">starred</span>
191
+
<span class="font-medium">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
192
+
{{if not .Read}}
193
+
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
194
+
{{end}}
195
+
</div>
196
+
</div>
197
+
</div>
198
+
199
+
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
200
+
{{ template "repo/fragments/time" .Created }}
201
+
</div>
202
+
</div>
203
+
</a>
204
+
{{end}}
205
+
206
+
{{define "followNotification"}}
207
+
{{$url := printf "/%s" (resolve .ActorDid)}}
208
+
<a
209
+
href="{{$url}}"
210
+
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
211
+
>
212
+
<div class="flex items-center justify-between">
213
+
<div class="flex items-center gap-2 min-w-0 flex-1">
214
+
<span class="text-blue-600 dark:text-blue-400">
215
+
{{ i "user-plus" "w-4 h-4" }}
216
+
</span>
217
+
218
+
<div class="min-w-0 flex-1">
219
+
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
220
+
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
221
+
<span class="text-gray-500 dark:text-gray-400">followed you</span>
222
+
{{if not .Read}}
223
+
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
224
+
{{end}}
225
+
</div>
226
+
</div>
227
+
</div>
228
+
229
+
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
230
+
{{ template "repo/fragments/time" .Created }}
231
+
</div>
232
+
</div>
233
+
</a>
234
+
{{end}}
235
+
236
+
{{define "genericNotification"}}
237
+
<a
238
+
href="#"
239
+
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
240
+
>
241
+
<div class="flex items-center justify-between">
242
+
<div class="flex items-center gap-2 min-w-0 flex-1">
243
+
<span class="{{if not .Read}}text-blue-600 dark:text-blue-400{{else}}text-gray-500 dark:text-gray-400{{end}}">
244
+
{{ i "bell" "w-4 h-4" }}
245
+
</span>
246
+
247
+
<div class="min-w-0 flex-1">
248
+
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
249
+
<span>New notification</span>
250
+
{{if not .Read}}
251
+
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
252
+
{{end}}
253
+
</div>
254
+
</div>
255
+
</div>
256
+
257
+
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
258
+
{{ template "repo/fragments/time" .Created }}
259
+
</div>
260
+
</div>
261
+
</a>
262
+
{{end}}
+39
-62
appview/pages/templates/repo/blob.html
+39
-62
appview/pages/templates/repo/blob.html
···
11
11
{{ end }}
12
12
13
13
{{ define "repoContent" }}
14
+
{{ $lines := split .Contents }}
15
+
{{ $tot_lines := len $lines }}
16
+
{{ $tot_chars := len (printf "%d" $tot_lines) }}
17
+
{{ $code_number_style := "text-gray-400 dark:text-gray-500 left-0 bg-white dark:bg-gray-800 text-right mr-6 select-none inline-block w-12" }}
14
18
{{ $linkstyle := "no-underline hover:underline" }}
15
19
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
16
20
<div class="flex flex-col md:flex-row md:justify-between gap-2">
···
32
36
</div>
33
37
<div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
34
38
<span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span>
35
-
36
-
{{ if .BlobView.ShowingText }}
37
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
38
-
<span>{{ .Lines }} lines</span>
39
-
{{ end }}
40
-
41
-
{{ if .BlobView.SizeHint }}
42
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
43
-
<span>{{ byteFmt .BlobView.SizeHint }}</span>
44
-
{{ end }}
45
-
46
-
{{ if .BlobView.HasRawView }}
47
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
48
-
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
49
-
{{ end }}
50
-
51
-
{{ if .BlobView.ShowToggle }}
52
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
53
-
<a href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .BlobView.ShowingRendered }}" hx-boost="true">
54
-
view {{ if .BlobView.ShowingRendered }}code{{ else }}rendered{{ end }}
55
-
</a>
39
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
40
+
<span>{{ .Lines }} lines</span>
41
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
42
+
<span>{{ byteFmt .SizeHint }}</span>
43
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
44
+
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
45
+
{{ if .RenderToggle }}
46
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
47
+
<a
48
+
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
49
+
hx-boost="true"
50
+
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
56
51
{{ end }}
57
52
</div>
58
53
</div>
59
54
</div>
60
-
{{ if .BlobView.IsUnsupported }}
61
-
<p class="text-center text-gray-400 dark:text-gray-500">
62
-
Previews are not supported for this file type.
63
-
</p>
64
-
{{ else if .BlobView.ContentType.IsSubmodule }}
65
-
<p class="text-center text-gray-400 dark:text-gray-500">
66
-
This directory is a git submodule of <a href="{{ .BlobView.ContentSrc }}">{{ .BlobView.ContentSrc }}</a>.
67
-
</p>
68
-
{{ else if .BlobView.ContentType.IsImage }}
69
-
<div class="text-center">
70
-
<img src="{{ .BlobView.ContentSrc }}"
71
-
alt="{{ .Path }}"
72
-
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
73
-
</div>
74
-
{{ else if .BlobView.ContentType.IsVideo }}
75
-
<div class="text-center">
76
-
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
77
-
<source src="{{ .BlobView.ContentSrc }}">
78
-
Your browser does not support the video tag.
79
-
</video>
80
-
</div>
81
-
{{ else if .BlobView.ContentType.IsSvg }}
82
-
<div class="overflow-auto relative">
83
-
{{ if .BlobView.ShowingRendered }}
84
-
<div class="text-center">
85
-
<img src="{{ .BlobView.ContentSrc }}"
86
-
alt="{{ .Path }}"
87
-
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
88
-
</div>
55
+
{{ if and .IsBinary .Unsupported }}
56
+
<p class="text-center text-gray-400 dark:text-gray-500">
57
+
Previews are not supported for this file type.
58
+
</p>
59
+
{{ else if .IsBinary }}
60
+
<div class="text-center">
61
+
{{ if .IsImage }}
62
+
<img src="{{ .ContentSrc }}"
63
+
alt="{{ .Path }}"
64
+
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
65
+
{{ else if .IsVideo }}
66
+
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
67
+
<source src="{{ .ContentSrc }}">
68
+
Your browser does not support the video tag.
69
+
</video>
70
+
{{ end }}
71
+
</div>
72
+
{{ else }}
73
+
<div class="overflow-auto relative">
74
+
{{ if .ShowRendered }}
75
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
89
76
{{ else }}
90
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
77
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div>
91
78
{{ end }}
92
-
</div>
93
-
{{ else if .BlobView.ContentType.IsMarkup }}
94
-
<div class="overflow-auto relative">
95
-
{{ if .BlobView.ShowingRendered }}
96
-
<div id="blob-contents" class="prose dark:prose-invert">{{ .BlobView.Contents | readme }}</div>
97
-
{{ else }}
98
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
99
-
{{ end }}
100
-
</div>
101
-
{{ else if .BlobView.ContentType.IsCode }}
102
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
79
+
</div>
103
80
{{ end }}
104
81
{{ template "fragments/multiline-select" }}
105
82
{{ end }}
+14
-14
appview/pages/templates/repo/commit.html
+14
-14
appview/pages/templates/repo/commit.html
···
24
24
</div>
25
25
</div>
26
26
27
-
<div class="flex flex-wrap items-center space-x-2">
28
-
<p class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-300">
29
-
{{ $did := index $.EmailToDid $commit.Author.Email }}
27
+
<div class="flex items-center space-x-2">
28
+
<p class="text-sm text-gray-500 dark:text-gray-300">
29
+
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
30
30
31
-
{{ if $did }}
32
-
{{ template "user/fragments/picHandleLink" $did }}
31
+
{{ if $didOrHandle }}
32
+
<a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a>
33
33
{{ else }}
34
34
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a>
35
35
{{ end }}
36
-
37
36
<span class="px-1 select-none before:content-['\00B7']"></span>
38
37
{{ template "repo/fragments/time" $commit.Author.When }}
39
38
<span class="px-1 select-none before:content-['\00B7']"></span>
39
+
</p>
40
40
41
+
<p class="flex items-center text-sm text-gray-500 dark:text-gray-300">
41
42
<a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a>
42
-
43
43
{{ if $commit.Parent }}
44
-
{{ i "arrow-left" "w-3 h-3 mx-1" }}
45
-
<a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a>
44
+
{{ i "arrow-left" "w-3 h-3 mx-1" }}
45
+
<a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a>
46
46
{{ end }}
47
47
</p>
48
48
···
58
58
<div class="mb-1">This commit was signed with the committer's <span class="text-green-600 font-semibold">known signature</span>.</div>
59
59
<div class="flex items-center gap-2 my-2">
60
60
{{ i "user" "w-4 h-4" }}
61
-
{{ $committerDid := index $.EmailToDid $commit.Committer.Email }}
62
-
{{ template "user/fragments/picHandleLink" $committerDid }}
61
+
{{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }}
62
+
<a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a>
63
63
</div>
64
64
<div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700">
65
65
<div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
···
80
80
{{end}}
81
81
82
82
{{ define "topbarLayout" }}
83
-
<header class="col-span-full" style="z-index: 20;">
83
+
<header class="px-1 col-span-full" style="z-index: 20;">
84
84
{{ template "layouts/fragments/topbar" . }}
85
85
</header>
86
86
{{ end }}
87
87
88
88
{{ define "mainLayout" }}
89
-
<div class="px-1 flex-grow col-span-full flex flex-col gap-4">
89
+
<div class="px-1 col-span-full flex flex-col gap-4">
90
90
{{ block "contentLayout" . }}
91
91
{{ block "content" . }}{{ end }}
92
92
{{ end }}
···
105
105
{{ end }}
106
106
107
107
{{ define "footerLayout" }}
108
-
<footer class="col-span-full mt-12">
108
+
<footer class="px-1 col-span-full mt-12">
109
109
{{ template "layouts/fragments/footer" . }}
110
110
</footer>
111
111
{{ end }}
+1
-1
appview/pages/templates/repo/empty.html
+1
-1
appview/pages/templates/repo/empty.html
···
35
35
36
36
<p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p>
37
37
<p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p>
38
-
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
38
+
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
39
39
<p><span class="{{$bullet}}">4</span>Push!</p>
40
40
</div>
41
41
</div>
-7
appview/pages/templates/repo/fork.html
-7
appview/pages/templates/repo/fork.html
···
6
6
</div>
7
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
8
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
-
10
-
<fieldset class="space-y-3">
11
-
<legend for="repo_name" class="dark:text-white">Repository name</legend>
12
-
<input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}"
13
-
class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" />
14
-
</fieldset>
15
-
16
9
<fieldset class="space-y-3">
17
10
<legend class="dark:text-white">Select a knot to fork into</legend>
18
11
<div class="space-y-2">
+5
-5
appview/pages/templates/repo/fragments/cloneDropdown.html
+5
-5
appview/pages/templates/repo/fragments/cloneDropdown.html
···
1
1
{{ define "repo/fragments/cloneDropdown" }}
2
2
{{ $knot := .RepoInfo.Knot }}
3
3
{{ if eq $knot "knot1.tangled.sh" }}
4
-
{{ $knot = "tangled.org" }}
4
+
{{ $knot = "tangled.sh" }}
5
5
{{ end }}
6
6
7
7
<details id="clone-dropdown" class="relative inline-block text-left group">
···
29
29
<code
30
30
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
31
31
onclick="window.getSelection().selectAllChildren(this)"
32
-
data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}"
33
-
>https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code>
32
+
data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
+
>https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
34
34
<button
35
35
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
36
36
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
···
48
48
<code
49
49
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
50
50
onclick="window.getSelection().selectAllChildren(this)"
51
-
data-url="git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
-
>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
51
+
data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
+
>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
53
53
<button
54
54
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
55
55
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+18
-20
appview/pages/templates/repo/fragments/diffOpts.html
+18
-20
appview/pages/templates/repo/fragments/diffOpts.html
···
5
5
{{ if .Split }}
6
6
{{ $active = "split" }}
7
7
{{ end }}
8
-
9
-
{{ $unified :=
10
-
(dict
11
-
"Key" "unified"
12
-
"Value" "unified"
13
-
"Icon" "square-split-vertical"
14
-
"Meta" "") }}
15
-
{{ $split :=
16
-
(dict
17
-
"Key" "split"
18
-
"Value" "split"
19
-
"Icon" "square-split-horizontal"
20
-
"Meta" "") }}
21
-
{{ $values := list $unified $split }}
22
-
23
-
{{ template "fragments/tabSelector"
24
-
(dict
25
-
"Name" "diff"
26
-
"Values" $values
27
-
"Active" $active) }}
8
+
{{ $values := list "unified" "split" }}
9
+
{{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }}
28
10
</section>
29
11
{{ end }}
30
12
13
+
{{ define "tabSelector" }}
14
+
{{ $name := .Name }}
15
+
{{ $all := .Values }}
16
+
{{ $active := .Active }}
17
+
<div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
18
+
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
19
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
20
+
{{ range $index, $value := $all }}
21
+
{{ $isActive := eq $value $active }}
22
+
<a href="?{{ $name }}={{ $value }}"
23
+
class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
24
+
{{ $value }}
25
+
</a>
26
+
{{ end }}
27
+
</div>
28
+
{{ end }}
+11
appview/pages/templates/repo/fragments/editRepoDescription.html
+11
appview/pages/templates/repo/fragments/editRepoDescription.html
···
1
+
{{ define "repo/fragments/editRepoDescription" }}
2
+
<form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2">
3
+
<input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}">
4
+
<button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm">
5
+
{{ i "check" "w-3 h-3" }} save
6
+
</button>
7
+
<button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" >
8
+
{{ i "x" "w-3 h-3" }} cancel
9
+
</button>
10
+
</form>
11
+
{{ end }}
-48
appview/pages/templates/repo/fragments/externalLinkPanel.html
-48
appview/pages/templates/repo/fragments/externalLinkPanel.html
···
1
-
{{ define "repo/fragments/externalLinkPanel" }}
2
-
<div id="at-uri-panel" class="px-2 md:px-0">
3
-
<div class="flex justify-between items-center gap-2">
4
-
<span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">AT URI</span>
5
-
<div class="flex items-center gap-2">
6
-
<button
7
-
onclick="copyToClipboard(this)"
8
-
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
9
-
title="Copy to clipboard">
10
-
{{ i "copy" "w-4 h-4" }}
11
-
</button>
12
-
<a
13
-
href="https://pdsls.dev/{{.}}"
14
-
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
15
-
title="View in PDSls">
16
-
{{ i "arrow-up-right" "w-4 h-4" }}
17
-
</a>
18
-
</div>
19
-
</div>
20
-
<span
21
-
class="font-mono text-sm select-all cursor-pointer block max-w-full overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600"
22
-
onclick="window.getSelection().selectAllChildren(this)"
23
-
title="{{.}}"
24
-
data-aturi="{{ . | string | safeUrl }}"
25
-
>{{.}}</span>
26
-
27
-
28
-
</div>
29
-
30
-
<script>
31
-
function copyToClipboard(button) {
32
-
const container = document.getElementById("at-uri-panel");
33
-
const urlSpan = container?.querySelector('[data-aturi]');
34
-
const text = urlSpan?.getAttribute('data-aturi');
35
-
console.log("copying to clipboard", text)
36
-
if (!text) return;
37
-
38
-
navigator.clipboard.writeText(text).then(() => {
39
-
const originalContent = button.innerHTML;
40
-
button.innerHTML = `{{ i "check" "w-4 h-4" }}`;
41
-
setTimeout(() => {
42
-
button.innerHTML = originalContent;
43
-
}, 2000);
44
-
});
45
-
}
46
-
</script>
47
-
{{ end }}
48
-
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+1
-9
appview/pages/templates/repo/fragments/og.html
+1
-9
appview/pages/templates/repo/fragments/og.html
···
2
2
{{ $title := or .Title .RepoInfo.FullName }}
3
3
{{ $description := or .Description .RepoInfo.Description }}
4
4
{{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }}
5
-
{{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }}
5
+
6
6
7
7
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
8
<meta property="og:type" content="object" />
9
9
<meta property="og:url" content="{{ $url }}" />
10
10
<meta property="og:description" content="{{ $description }}" />
11
-
<meta property="og:image" content="{{ $imageUrl }}" />
12
-
<meta property="og:image:width" content="1200" />
13
-
<meta property="og:image:height" content="600" />
14
-
15
-
<meta name="twitter:card" content="summary_large_image" />
16
-
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
-
<meta name="twitter:description" content="{{ $description }}" />
18
-
<meta name="twitter:image" content="{{ $imageUrl }}" />
19
11
{{ end }}
-26
appview/pages/templates/repo/fragments/participants.html
-26
appview/pages/templates/repo/fragments/participants.html
···
1
-
{{ define "repo/fragments/participants" }}
2
-
{{ $all := . }}
3
-
{{ $ps := take $all 5 }}
4
-
<div class="px-2 md:px-0">
5
-
<div class="py-1 flex items-center text-sm">
6
-
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
7
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
8
-
</div>
9
-
<div class="flex items-center -space-x-3 mt-2">
10
-
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
11
-
{{ range $i, $p := $ps }}
12
-
<img
13
-
src="{{ tinyAvatar . }}"
14
-
alt=""
15
-
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
16
-
/>
17
-
{{ end }}
18
-
19
-
{{ if gt (len $all) 5 }}
20
-
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
21
-
+{{ sub (len $all) 5 }}
22
-
</span>
23
-
{{ end }}
24
-
</div>
25
-
</div>
26
-
{{ end }}
+1
-6
appview/pages/templates/repo/fragments/reaction.html
+1
-6
appview/pages/templates/repo/fragments/reaction.html
···
2
2
<button
3
3
id="reactIndi-{{ .Kind }}"
4
4
class="flex justify-center items-center min-w-8 min-h-8 rounded border
5
-
leading-4 px-3 gap-1 relative group
5
+
leading-4 px-3 gap-1
6
6
{{ if eq .Count 0 }}
7
7
hidden
8
8
{{ end }}
···
20
20
dark:hover:border-gray-600
21
21
{{ end }}
22
22
"
23
-
{{ if gt (length .Users) 0 }}
24
-
title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}"
25
-
{{ else }}
26
-
title="{{ .Kind }}"
27
-
{{ end }}
28
23
{{ if .IsReacted }}
29
24
hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
30
25
{{ else }}
+15
appview/pages/templates/repo/fragments/repoDescription.html
+15
appview/pages/templates/repo/fragments/repoDescription.html
···
1
+
{{ define "repo/fragments/repoDescription" }}
2
+
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
3
+
{{ if .RepoInfo.Description }}
4
+
{{ .RepoInfo.Description | description }}
5
+
{{ else }}
6
+
<span class="italic">this repo has no description</span>
7
+
{{ end }}
8
+
9
+
{{ if .RepoInfo.Roles.IsOwner }}
10
+
<button class="flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit">
11
+
{{ i "pencil" "w-3 h-3" }}
12
+
</button>
13
+
{{ end }}
14
+
</span>
15
+
{{ end }}
+14
-11
appview/pages/templates/repo/index.html
+14
-11
appview/pages/templates/repo/index.html
···
35
35
{{ end }}
36
36
37
37
{{ define "repoLanguages" }}
38
-
<details class="group -my-4 -m-6 mb-4">
38
+
<details class="group -m-6 mb-4">
39
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
40
{{ range $value := .Languages }}
41
41
<div
···
129
129
{{ $icon := "folder" }}
130
130
{{ $iconStyle := "size-4 fill-current" }}
131
131
132
-
{{ if .IsSubmodule }}
133
-
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
134
-
{{ $icon = "folder-input" }}
135
-
{{ $iconStyle = "size-4" }}
136
-
{{ end }}
137
-
138
132
{{ if .IsFile }}
139
133
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
140
134
{{ $icon = "file" }}
141
135
{{ $iconStyle = "size-4" }}
142
136
{{ end }}
143
-
144
137
<a href="{{ $link }}" class="{{ $linkstyle }}">
145
138
<div class="flex items-center gap-2">
146
139
{{ i $icon $iconStyle "flex-shrink-0" }}
···
229
222
class="mx-1 before:content-['·'] before:select-none"
230
223
></span>
231
224
<span>
232
-
{{ $did := index $.EmailToDid .Author.Email }}
233
-
<a href="{{ if $did }}/{{ resolve $did }}{{ else }}mailto:{{ .Author.Email }}{{ end }}"
225
+
{{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }}
226
+
<a
227
+
href="{{ if $didOrHandle }}
228
+
/{{ $didOrHandle }}
229
+
{{ else }}
230
+
mailto:{{ .Author.Email }}
231
+
{{ end }}"
234
232
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
235
-
>{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ .Author.Name }}{{ end }}</a>
233
+
>{{ if $didOrHandle }}
234
+
{{ template "user/fragments/picHandleLink" $didOrHandle }}
235
+
{{ else }}
236
+
{{ .Author.Name }}
237
+
{{ end }}</a
238
+
>
236
239
</span>
237
240
<div class="inline-block px-1 select-none after:content-['·']"></div>
238
241
{{ template "repo/fragments/time" .Committer.When }}
-63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
-63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
···
1
-
{{ define "repo/issues/fragments/globalIssueListing" }}
2
-
<div class="flex flex-col gap-2">
3
-
{{ range .Issues }}
4
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
5
-
<div class="pb-2 mb-3">
6
-
<div class="flex items-center gap-3 mb-2">
7
-
<a
8
-
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}"
9
-
class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm"
10
-
>
11
-
{{ resolve .Repo.Did }}/{{ .Repo.Name }}
12
-
</a>
13
-
</div>
14
-
<a
15
-
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}"
16
-
class="no-underline hover:underline"
17
-
>
18
-
{{ .Title | description }}
19
-
<span class="text-gray-500">#{{ .IssueId }}</span>
20
-
</a>
21
-
</div>
22
-
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
23
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
24
-
{{ $icon := "ban" }}
25
-
{{ $state := "closed" }}
26
-
{{ if .Open }}
27
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
28
-
{{ $icon = "circle-dot" }}
29
-
{{ $state = "open" }}
30
-
{{ end }}
31
-
32
-
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
33
-
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
34
-
<span class="text-white dark:text-white">{{ $state }}</span>
35
-
</span>
36
-
37
-
<span class="ml-1">
38
-
{{ template "user/fragments/picHandleLink" .Did }}
39
-
</span>
40
-
41
-
<span class="before:content-['·']">
42
-
{{ template "repo/fragments/time" .Created }}
43
-
</span>
44
-
45
-
<span class="before:content-['·']">
46
-
{{ $s := "s" }}
47
-
{{ if eq (len .Comments) 1 }}
48
-
{{ $s = "" }}
49
-
{{ end }}
50
-
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
51
-
</span>
52
-
53
-
{{ $state := .Labels }}
54
-
{{ range $k, $d := $.LabelDefs }}
55
-
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
56
-
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
57
-
{{ end }}
58
-
{{ end }}
59
-
</div>
60
-
</div>
61
-
{{ end }}
62
-
</div>
63
-
{{ end }}
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
34
34
35
35
{{ define "editIssueComment" }}
36
36
<a
37
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
37
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
38
38
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
39
39
hx-swap="outerHTML"
40
40
hx-target="#comment-body-{{.Comment.Id}}">
···
44
44
45
45
{{ define "deleteIssueComment" }}
46
46
<a
47
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
47
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
48
48
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
49
49
hx-confirm="Are you sure you want to delete your comment?"
50
50
hx-swap="outerHTML"
-55
appview/pages/templates/repo/issues/fragments/issueListing.html
-55
appview/pages/templates/repo/issues/fragments/issueListing.html
···
1
-
{{ define "repo/issues/fragments/issueListing" }}
2
-
<div class="flex flex-col gap-2">
3
-
{{ range .Issues }}
4
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
5
-
<div class="pb-2">
6
-
<a
7
-
href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}"
8
-
class="no-underline hover:underline"
9
-
>
10
-
{{ .Title | description }}
11
-
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
12
-
</a>
13
-
</div>
14
-
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
15
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
16
-
{{ $icon := "ban" }}
17
-
{{ $state := "closed" }}
18
-
{{ if .Open }}
19
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
20
-
{{ $icon = "circle-dot" }}
21
-
{{ $state = "open" }}
22
-
{{ end }}
23
-
24
-
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
25
-
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
26
-
<span class="text-white dark:text-white">{{ $state }}</span>
27
-
</span>
28
-
29
-
<span class="ml-1">
30
-
{{ template "user/fragments/picHandleLink" .Did }}
31
-
</span>
32
-
33
-
<span class="before:content-['·']">
34
-
{{ template "repo/fragments/time" .Created }}
35
-
</span>
36
-
37
-
<span class="before:content-['·']">
38
-
{{ $s := "s" }}
39
-
{{ if eq (len .Comments) 1 }}
40
-
{{ $s = "" }}
41
-
{{ end }}
42
-
<a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
43
-
</span>
44
-
45
-
{{ $state := .Labels }}
46
-
{{ range $k, $d := $.LabelDefs }}
47
-
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
48
-
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
49
-
{{ end }}
50
-
{{ end }}
51
-
</div>
52
-
</div>
53
-
{{ end }}
54
-
</div>
55
-
{{ end }}
+2
-7
appview/pages/templates/repo/issues/fragments/newComment.html
+2
-7
appview/pages/templates/repo/issues/fragments/newComment.html
···
138
138
</div>
139
139
</form>
140
140
{{ else }}
141
-
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center">
142
-
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
143
-
sign up
144
-
</a>
145
-
<span class="text-gray-500 dark:text-gray-400">or</span>
146
-
<a href="/login" class="underline">login</a>
147
-
to add to the discussion
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
148
143
</div>
149
144
{{ end }}
150
145
{{ end }}
-19
appview/pages/templates/repo/issues/fragments/og.html
-19
appview/pages/templates/repo/issues/fragments/og.html
···
1
-
{{ define "repo/issues/fragments/og" }}
2
-
{{ $title := printf "%s #%d" .Issue.Title .Issue.IssueId }}
3
-
{{ $description := or .Issue.Body .RepoInfo.Description }}
4
-
{{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
5
-
{{ $imageUrl := printf "https://tangled.org/%s/issues/%d/opengraph" .RepoInfo.FullName .Issue.IssueId }}
6
-
7
-
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
-
<meta property="og:type" content="object" />
9
-
<meta property="og:url" content="{{ $url }}" />
10
-
<meta property="og:description" content="{{ $description }}" />
11
-
<meta property="og:image" content="{{ $imageUrl }}" />
12
-
<meta property="og:image:width" content="1200" />
13
-
<meta property="og:image:height" content="600" />
14
-
15
-
<meta name="twitter:card" content="summary_large_image" />
16
-
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
-
<meta name="twitter:description" content="{{ $description }}" />
18
-
<meta name="twitter:image" content="{{ $imageUrl }}" />
19
-
{{ end }}
+35
-9
appview/pages/templates/repo/issues/issue.html
+35
-9
appview/pages/templates/repo/issues/issue.html
···
2
2
3
3
4
4
{{ define "extrameta" }}
5
-
{{ template "repo/issues/fragments/og" (dict "RepoInfo" .RepoInfo "Issue" .Issue) }}
5
+
{{ $title := printf "%s · issue #%d · %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }}
6
+
{{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
7
+
8
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
6
9
{{ end }}
7
10
8
11
{{ define "repoContentLayout" }}
···
19
22
"Defs" $.LabelDefs
20
23
"Subject" $.Issue.AtUri
21
24
"State" $.Issue.Labels) }}
22
-
{{ template "repo/fragments/participants" $.Issue.Participants }}
23
-
{{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }}
25
+
{{ template "issueParticipants" . }}
24
26
</div>
25
27
</div>
26
28
{{ end }}
···
85
87
86
88
{{ define "editIssue" }}
87
89
<a
88
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
90
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
89
91
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
90
92
hx-swap="innerHTML"
91
93
hx-target="#issue-{{.Issue.IssueId}}">
···
95
97
96
98
{{ define "deleteIssue" }}
97
99
<a
98
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
100
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
99
101
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/"
100
102
hx-confirm="Are you sure you want to delete your issue?"
101
103
hx-swap="none">
···
108
110
<div class="flex items-center gap-2">
109
111
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
110
112
{{ range $kind := .OrderedReactionKinds }}
111
-
{{ $reactionData := index $.Reactions $kind }}
112
113
{{
113
114
template "repo/fragments/reaction"
114
115
(dict
115
116
"Kind" $kind
116
-
"Count" $reactionData.Count
117
+
"Count" (index $.Reactions $kind)
117
118
"IsReacted" (index $.UserReacted $kind)
118
-
"ThreadAt" $.Issue.AtUri
119
-
"Users" $reactionData.Users)
119
+
"ThreadAt" $.Issue.AtUri)
120
120
}}
121
121
{{ end }}
122
122
</div>
123
123
{{ end }}
124
124
125
+
{{ define "issueParticipants" }}
126
+
{{ $all := .Issue.Participants }}
127
+
{{ $ps := take $all 5 }}
128
+
<div>
129
+
<div class="py-1 flex items-center text-sm">
130
+
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
131
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
132
+
</div>
133
+
<div class="flex items-center -space-x-3 mt-2">
134
+
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
135
+
{{ range $i, $p := $ps }}
136
+
<img
137
+
src="{{ tinyAvatar . }}"
138
+
alt=""
139
+
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
140
+
/>
141
+
{{ end }}
142
+
143
+
{{ if gt (len $all) 5 }}
144
+
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
145
+
+{{ sub (len $all) 5 }}
146
+
</span>
147
+
{{ end }}
148
+
</div>
149
+
</div>
150
+
{{ end }}
125
151
126
152
{{ define "repoAfter" }}
127
153
<div class="flex flex-col gap-4 mt-4">
+76
-45
appview/pages/templates/repo/issues/issues.html
+76
-45
appview/pages/templates/repo/issues/issues.html
···
8
8
{{ end }}
9
9
10
10
{{ define "repoContent" }}
11
-
{{ $active := "closed" }}
12
-
{{ if .FilteringByOpen }}
13
-
{{ $active = "open" }}
14
-
{{ end }}
15
-
16
-
{{ $open :=
17
-
(dict
18
-
"Key" "open"
19
-
"Value" "open"
20
-
"Icon" "circle-dot"
21
-
"Meta" (string .RepoInfo.Stats.IssueCount.Open)) }}
22
-
{{ $closed :=
23
-
(dict
24
-
"Key" "closed"
25
-
"Value" "closed"
26
-
"Icon" "ban"
27
-
"Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }}
28
-
{{ $values := list $open $closed }}
29
-
30
-
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
31
-
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
32
-
<input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}">
33
-
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
34
-
{{ i "search" "w-4 h-4" }}
35
-
</div>
36
-
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
37
-
<a
38
-
href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"
39
-
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
40
-
>
41
-
{{ i "x" "w-4 h-4" }}
42
-
</a>
43
-
</form>
44
-
<div class="sm:row-start-1">
45
-
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
46
-
</div>
11
+
<div class="flex justify-between items-center gap-4">
12
+
<div class="flex gap-4">
13
+
<a
14
+
href="?state=open"
15
+
class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
16
+
>
17
+
{{ i "circle-dot" "w-4 h-4" }}
18
+
<span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span>
19
+
</a>
47
20
<a
21
+
href="?state=closed"
22
+
class="flex items-center gap-2 {{ if not .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
23
+
>
24
+
{{ i "ban" "w-4 h-4" }}
25
+
<span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span>
26
+
</a>
27
+
</div>
28
+
<a
48
29
href="/{{ .RepoInfo.FullName }}/issues/new"
49
-
class="col-start-3 btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
50
-
>
30
+
class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
31
+
>
51
32
{{ i "circle-plus" "w-4 h-4" }}
52
33
<span>new</span>
53
-
</a>
54
-
</div>
55
-
<div class="error" id="issues"></div>
34
+
</a>
35
+
</div>
36
+
<div class="error" id="issues"></div>
56
37
{{ end }}
57
38
58
39
{{ define "repoAfter" }}
59
-
<div class="mt-2">
60
-
{{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }}
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
+
<div 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
+
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
+
67
+
<span class="ml-1">
68
+
{{ template "user/fragments/picHandleLink" .Did }}
69
+
</span>
70
+
71
+
<span class="before:content-['·']">
72
+
{{ template "repo/fragments/time" .Created }}
73
+
</span>
74
+
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
+
83
+
{{ $state := .Labels }}
84
+
{{ range $k, $d := $.LabelDefs }}
85
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
86
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
87
+
{{ end }}
88
+
{{ end }}
89
+
</div>
90
+
</div>
91
+
{{ end }}
61
92
</div>
62
93
{{ block "pagination" . }} {{ end }}
63
94
{{ end }}
···
74
105
<a
75
106
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
76
107
hx-boost="true"
77
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
108
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
78
109
>
79
110
{{ i "chevron-left" "w-4 h-4" }}
80
111
previous
···
88
119
<a
89
120
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
90
121
hx-boost="true"
91
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
122
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
92
123
>
93
124
next
94
125
{{ i "chevron-right" "w-4 h-4" }}
+6
-6
appview/pages/templates/repo/log.html
+6
-6
appview/pages/templates/repo/log.html
···
27
27
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
28
28
<div class="{{ $grid }} py-3">
29
29
<div class="align-top truncate col-span-2">
30
-
{{ $did := index $.EmailToDid $commit.Author.Email }}
31
-
{{ if $did }}
32
-
{{ template "user/fragments/picHandleLink" $did }}
30
+
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
31
+
{{ if $didOrHandle }}
32
+
{{ template "user/fragments/picHandleLink" $didOrHandle }}
33
33
{{ else }}
34
34
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
35
35
{{ end }}
···
153
153
</span>
154
154
<span class="mx-2 before:content-['·'] before:select-none"></span>
155
155
<span>
156
-
{{ $did := index $.EmailToDid $commit.Author.Email }}
157
-
<a href="{{ if $did }}/{{ $did }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
156
+
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
157
+
<a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
158
158
class="text-gray-500 dark:text-gray-400 no-underline hover:underline">
159
-
{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ $commit.Author.Name }}{{ end }}
159
+
{{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }}
160
160
</a>
161
161
</span>
162
162
<div class="inline-block px-1 select-none after:content-['·']"></div>
+6
-7
appview/pages/templates/repo/pipelines/fragments/logBlock.html
+6
-7
appview/pages/templates/repo/pipelines/fragments/logBlock.html
···
2
2
<div id="lines" hx-swap-oob="beforeend">
3
3
<details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700">
4
4
<summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400">
5
-
<div class="group-open:hidden flex items-center gap-1">{{ template "stepHeader" . }}</div>
6
-
<div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</div>
5
+
<div class="group-open:hidden flex items-center gap-1">
6
+
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
7
+
</div>
8
+
<div class="hidden group-open:flex items-center gap-1">
9
+
{{ i "chevron-down" "w-4 h-4" }} {{ .Name }}
10
+
</div>
7
11
</summary>
8
12
<div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div>
9
13
</details>
10
14
</div>
11
15
{{ end }}
12
-
13
-
{{ define "stepHeader" }}
14
-
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
15
-
<span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span>
16
-
{{ end }}
-9
appview/pages/templates/repo/pipelines/fragments/logBlockEnd.html
-9
appview/pages/templates/repo/pipelines/fragments/logBlockEnd.html
···
1
-
{{ define "repo/pipelines/fragments/logBlockEnd" }}
2
-
<span
3
-
class="ml-auto text-sm text-gray-500 tabular-nums"
4
-
data-timer="{{ .Id }}"
5
-
data-start="{{ .StartTime.Unix }}"
6
-
data-end="{{ .EndTime.Unix }}"
7
-
hx-swap-oob="outerHTML:[data-timer='{{ .Id }}']"></span>
8
-
{{ end }}
9
-
+3
-15
appview/pages/templates/repo/pipelines/pipelines.html
+3
-15
appview/pages/templates/repo/pipelines/pipelines.html
···
12
12
{{ range .Pipelines }}
13
13
{{ block "pipeline" (list $ .) }} {{ end }}
14
14
{{ else }}
15
-
<div class="py-6 w-fit flex flex-col gap-4 mx-auto">
16
-
<p>
17
-
No pipelines have been run for this repository yet. To get started:
18
-
</p>
19
-
{{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }}
20
-
<p>
21
-
<span class="{{ $bullet }}">1</span>First, choose a spindle in your
22
-
<a href="/{{ .RepoInfo.FullName }}/settings?tab=pipelines" class="underline">repository settings</a>.
23
-
</p>
24
-
<p>
25
-
<span class="{{ $bullet }}">2</span>Configure your CI/CD
26
-
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>.
27
-
</p>
28
-
<p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p>
29
-
</div>
15
+
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
16
+
No pipelines run for this repository.
17
+
</p>
30
18
{{ end }}
31
19
</div>
32
20
</div>
-6
appview/pages/templates/repo/pipelines/workflow.html
-6
appview/pages/templates/repo/pipelines/workflow.html
···
15
15
{{ block "logs" . }} {{ end }}
16
16
</div>
17
17
</section>
18
-
{{ template "fragments/workflow-timers" }}
19
18
{{ end }}
20
19
21
20
{{ define "sidebar" }}
···
59
58
hx-ext="ws"
60
59
ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs">
61
60
<div id="lines" class="flex flex-col gap-2">
62
-
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 only:flex hidden border border-gray-200 dark:border-gray-700 rounded">
63
-
<span class="flex items-center gap-2">
64
-
{{ i "triangle-alert" "size-4" }} No logs for this workflow
65
-
</span>
66
-
</div>
67
61
</div>
68
62
</div>
69
63
{{ end }}
-19
appview/pages/templates/repo/pulls/fragments/og.html
-19
appview/pages/templates/repo/pulls/fragments/og.html
···
1
-
{{ define "repo/pulls/fragments/og" }}
2
-
{{ $title := printf "%s #%d" .Pull.Title .Pull.PullId }}
3
-
{{ $description := or .Pull.Body .RepoInfo.Description }}
4
-
{{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
5
-
{{ $imageUrl := printf "https://tangled.org/%s/pulls/%d/opengraph" .RepoInfo.FullName .Pull.PullId }}
6
-
7
-
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
-
<meta property="og:type" content="object" />
9
-
<meta property="og:url" content="{{ $url }}" />
10
-
<meta property="og:description" content="{{ $description }}" />
11
-
<meta property="og:image" content="{{ $imageUrl }}" />
12
-
<meta property="og:image:width" content="1200" />
13
-
<meta property="og:image:height" content="600" />
14
-
15
-
<meta name="twitter:card" content="summary_large_image" />
16
-
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
-
<meta name="twitter:description" content="{{ $description }}" />
18
-
<meta name="twitter:image" content="{{ $imageUrl }}" />
19
-
{{ end }}
+72
-81
appview/pages/templates/repo/pulls/fragments/pullActions.html
+72
-81
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
22
22
{{ $isLastRound := eq $roundNumber $lastIdx }}
23
23
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
24
24
{{ $isUpToDate := .ResubmitCheck.No }}
25
-
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative">
26
-
<button
27
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
28
-
hx-target="#actions-{{$roundNumber}}"
29
-
hx-swap="outerHtml"
30
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
31
-
{{ i "message-square-plus" "w-4 h-4" }}
32
-
<span>comment</span>
33
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
34
-
</button>
35
-
{{ if .BranchDeleteStatus }}
36
-
<button
37
-
hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches"
38
-
hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }'
39
-
hx-swap="none"
40
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
41
-
{{ i "git-branch" "w-4 h-4" }}
42
-
<span>delete branch</span>
43
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
44
-
</button>
45
-
{{ end }}
46
-
{{ if and $isPushAllowed $isOpen $isLastRound }}
47
-
{{ $disabled := "" }}
48
-
{{ if $isConflicted }}
49
-
{{ $disabled = "disabled" }}
50
-
{{ end }}
51
-
<button
52
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
53
-
hx-swap="none"
54
-
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
55
-
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
56
-
{{ i "git-merge" "w-4 h-4" }}
57
-
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
58
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
59
-
</button>
60
-
{{ end }}
25
+
<div class="relative w-fit">
26
+
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
27
+
<button
28
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
29
+
hx-target="#actions-{{$roundNumber}}"
30
+
hx-swap="outerHtml"
31
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
32
+
{{ i "message-square-plus" "w-4 h-4" }}
33
+
<span>comment</span>
34
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
35
+
</button>
36
+
{{ if and $isPushAllowed $isOpen $isLastRound }}
37
+
{{ $disabled := "" }}
38
+
{{ if $isConflicted }}
39
+
{{ $disabled = "disabled" }}
40
+
{{ end }}
41
+
<button
42
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
43
+
hx-swap="none"
44
+
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
45
+
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
46
+
{{ i "git-merge" "w-4 h-4" }}
47
+
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
48
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
49
+
</button>
50
+
{{ end }}
61
51
62
-
{{ if and $isPullAuthor $isOpen $isLastRound }}
63
-
{{ $disabled := "" }}
64
-
{{ if $isUpToDate }}
65
-
{{ $disabled = "disabled" }}
66
-
{{ end }}
67
-
<button id="resubmitBtn"
68
-
{{ if not .Pull.IsPatchBased }}
69
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
70
-
{{ else }}
71
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
72
-
hx-target="#actions-{{$roundNumber}}"
73
-
hx-swap="outerHtml"
52
+
{{ if and $isPullAuthor $isOpen $isLastRound }}
53
+
{{ $disabled := "" }}
54
+
{{ if $isUpToDate }}
55
+
{{ $disabled = "disabled" }}
74
56
{{ end }}
57
+
<button id="resubmitBtn"
58
+
{{ if not .Pull.IsPatchBased }}
59
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
60
+
{{ else }}
61
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
62
+
hx-target="#actions-{{$roundNumber}}"
63
+
hx-swap="outerHtml"
64
+
{{ end }}
75
65
76
-
hx-disabled-elt="#resubmitBtn"
77
-
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
66
+
hx-disabled-elt="#resubmitBtn"
67
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
78
68
79
-
{{ if $disabled }}
80
-
title="Update this branch to resubmit this pull request"
81
-
{{ else }}
82
-
title="Resubmit this pull request"
83
-
{{ end }}
84
-
>
85
-
{{ i "rotate-ccw" "w-4 h-4" }}
86
-
<span>resubmit</span>
87
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
88
-
</button>
89
-
{{ end }}
69
+
{{ if $disabled }}
70
+
title="Update this branch to resubmit this pull request"
71
+
{{ else }}
72
+
title="Resubmit this pull request"
73
+
{{ end }}
74
+
>
75
+
{{ i "rotate-ccw" "w-4 h-4" }}
76
+
<span>resubmit</span>
77
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
78
+
</button>
79
+
{{ end }}
90
80
91
-
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
92
-
<button
93
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
94
-
hx-swap="none"
95
-
class="btn p-2 flex items-center gap-2 group">
96
-
{{ i "ban" "w-4 h-4" }}
97
-
<span>close</span>
98
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
99
-
</button>
100
-
{{ end }}
81
+
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
82
+
<button
83
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
84
+
hx-swap="none"
85
+
class="btn p-2 flex items-center gap-2 group">
86
+
{{ i "ban" "w-4 h-4" }}
87
+
<span>close</span>
88
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
89
+
</button>
90
+
{{ end }}
101
91
102
-
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
103
-
<button
104
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
105
-
hx-swap="none"
106
-
class="btn p-2 flex items-center gap-2 group">
107
-
{{ i "refresh-ccw-dot" "w-4 h-4" }}
108
-
<span>reopen</span>
109
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
110
-
</button>
111
-
{{ end }}
92
+
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
93
+
<button
94
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
95
+
hx-swap="none"
96
+
class="btn p-2 flex items-center gap-2 group">
97
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
98
+
<span>reopen</span>
99
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
100
+
</button>
101
+
{{ end }}
102
+
</div>
112
103
</div>
113
104
{{ end }}
114
105
+11
-15
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+11
-15
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
42
42
{{ if not .Pull.IsPatchBased }}
43
43
from
44
44
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
45
-
{{ if not .Pull.IsForkBased }}
46
-
{{ $repoPath := .RepoInfo.FullName }}
47
-
<a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
48
-
{{ else if .Pull.PullSource.Repo }}
49
-
{{ $repoPath := print (resolve .Pull.PullSource.Repo.Did) "/" .Pull.PullSource.Repo.Name }}
50
-
<a href="/{{ $repoPath }}" class="no-underline hover:underline">{{ $repoPath }}</a>:
51
-
<a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
52
-
{{ else }}
53
-
<span class="italic">[deleted fork]</span>:
54
-
{{ .Pull.PullSource.Branch }}
55
-
{{ end }}
45
+
{{ if .Pull.IsForkBased }}
46
+
{{ if .Pull.PullSource.Repo }}
47
+
{{ $owner := resolve .Pull.PullSource.Repo.Did }}
48
+
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>:
49
+
{{- else -}}
50
+
<span class="italic">[deleted fork]</span>
51
+
{{- end -}}
52
+
{{- end -}}
53
+
{{- .Pull.PullSource.Branch -}}
56
54
</span>
57
55
{{ end }}
58
56
</span>
···
68
66
<div class="flex items-center gap-2 mt-2">
69
67
{{ template "repo/fragments/reactionsPopUp" . }}
70
68
{{ range $kind := . }}
71
-
{{ $reactionData := index $.Reactions $kind }}
72
69
{{
73
70
template "repo/fragments/reaction"
74
71
(dict
75
72
"Kind" $kind
76
-
"Count" $reactionData.Count
73
+
"Count" (index $.Reactions $kind)
77
74
"IsReacted" (index $.UserReacted $kind)
78
-
"ThreadAt" $.Pull.AtUri
79
-
"Users" $reactionData.Users)
75
+
"ThreadAt" $.Pull.PullAt)
80
76
}}
81
77
{{ end }}
82
78
</div>
+1
-1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
3
3
id="pull-comment-card-{{ .RoundNumber }}"
4
4
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
5
<div class="text-sm text-gray-500 dark:text-gray-400">
6
-
{{ resolve .LoggedInUser.Did }}
6
+
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
7
7
</div>
8
8
<form
9
9
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+14
-1
appview/pages/templates/repo/pulls/interdiff.html
+14
-1
appview/pages/templates/repo/pulls/interdiff.html
···
28
28
29
29
{{ end }}
30
30
31
+
{{ define "topbarLayout" }}
32
+
<header class="px-1 col-span-full" style="z-index: 20;">
33
+
{{ template "layouts/fragments/topbar" . }}
34
+
</header>
35
+
{{ end }}
36
+
31
37
{{ define "mainLayout" }}
32
-
<div class="px-1 col-span-full flex-grow flex flex-col gap-4">
38
+
<div class="px-1 col-span-full flex flex-col gap-4">
33
39
{{ block "contentLayout" . }}
34
40
{{ block "content" . }}{{ end }}
35
41
{{ end }}
···
46
52
{{ end }}
47
53
</div>
48
54
{{ end }}
55
+
56
+
{{ define "footerLayout" }}
57
+
<footer class="px-1 col-span-full mt-12">
58
+
{{ template "layouts/fragments/footer" . }}
59
+
</footer>
60
+
{{ end }}
61
+
49
62
50
63
{{ define "contentAfter" }}
51
64
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+13
-1
appview/pages/templates/repo/pulls/patch.html
+13
-1
appview/pages/templates/repo/pulls/patch.html
···
34
34
</section>
35
35
{{ end }}
36
36
37
+
{{ define "topbarLayout" }}
38
+
<header class="px-1 col-span-full" style="z-index: 20;">
39
+
{{ template "layouts/fragments/topbar" . }}
40
+
</header>
41
+
{{ end }}
42
+
37
43
{{ define "mainLayout" }}
38
-
<div class="px-1 col-span-full flex-grow flex flex-col gap-4">
44
+
<div class="px-1 col-span-full flex flex-col gap-4">
39
45
{{ block "contentLayout" . }}
40
46
{{ block "content" . }}{{ end }}
41
47
{{ end }}
···
51
57
</div>
52
58
{{ end }}
53
59
</div>
60
+
{{ end }}
61
+
62
+
{{ define "footerLayout" }}
63
+
<footer class="px-1 col-span-full mt-12">
64
+
{{ template "layouts/fragments/footer" . }}
65
+
</footer>
54
66
{{ end }}
55
67
56
68
{{ define "contentAfter" }}
+20
-49
appview/pages/templates/repo/pulls/pull.html
+20
-49
appview/pages/templates/repo/pulls/pull.html
···
3
3
{{ end }}
4
4
5
5
{{ define "extrameta" }}
6
-
{{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }}
6
+
{{ $title := printf "%s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
7
+
{{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
8
+
9
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
7
10
{{ end }}
8
11
9
-
{{ define "repoContentLayout" }}
10
-
<div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full">
11
-
<div class="col-span-1 md:col-span-8">
12
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
13
-
{{ block "repoContent" . }}{{ end }}
14
-
</section>
15
-
{{ block "repoAfter" . }}{{ end }}
16
-
</div>
17
-
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
18
-
{{ template "repo/fragments/labelPanel"
19
-
(dict "RepoInfo" $.RepoInfo
20
-
"Defs" $.LabelDefs
21
-
"Subject" $.Pull.AtUri
22
-
"State" $.Pull.Labels) }}
23
-
{{ template "repo/fragments/participants" $.Pull.Participants }}
24
-
{{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }}
25
-
</div>
26
-
</div>
27
-
{{ end }}
28
12
29
13
{{ define "repoContent" }}
30
14
{{ template "repo/pulls/fragments/pullHeader" . }}
···
55
39
{{ with $item }}
56
40
<details {{ if eq $idx $lastIdx }}open{{ end }}>
57
41
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
58
-
<div class="flex flex-wrap gap-2 items-stretch">
42
+
<div class="flex flex-wrap gap-2 items-center">
59
43
<!-- round number -->
60
44
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
61
45
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
62
46
</div>
63
47
<!-- round summary -->
64
-
<div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
48
+
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
65
49
<span class="gap-1 flex items-center">
66
50
{{ $owner := resolve $.Pull.OwnerDid }}
67
51
{{ $re := "re" }}
···
88
72
<span class="hidden md:inline">diff</span>
89
73
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
90
74
</a>
91
-
{{ if ne $idx 0 }}
92
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
93
-
hx-boost="true"
94
-
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
95
-
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
96
-
<span class="hidden md:inline">interdiff</span>
97
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
98
-
</a>
75
+
{{ if not (eq .RoundNumber 0) }}
76
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
77
+
hx-boost="true"
78
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
79
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
80
+
<span class="hidden md:inline">interdiff</span>
81
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
82
+
</a>
83
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
99
84
{{ end }}
100
-
<span id="interdiff-error-{{.RoundNumber}}"></span>
101
85
</div>
102
86
</summary>
103
87
···
162
146
163
147
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
164
148
{{ range $cidx, $c := .Comments }}
165
-
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full">
149
+
<div id="comment-{{$c.ID}}" 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">
166
150
{{ if gt $cidx 0 }}
167
151
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
168
152
{{ end }}
···
185
169
{{ end }}
186
170
187
171
{{ if $.LoggedInUser }}
188
-
{{ template "repo/pulls/fragments/pullActions"
189
-
(dict
190
-
"LoggedInUser" $.LoggedInUser
191
-
"Pull" $.Pull
192
-
"RepoInfo" $.RepoInfo
193
-
"RoundNumber" .RoundNumber
194
-
"MergeCheck" $.MergeCheck
195
-
"ResubmitCheck" $.ResubmitCheck
196
-
"BranchDeleteStatus" $.BranchDeleteStatus
197
-
"Stack" $.Stack) }}
172
+
{{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }}
198
173
{{ else }}
199
-
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center w-fit">
200
-
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
201
-
sign up
202
-
</a>
203
-
<span class="text-gray-500 dark:text-gray-400">or</span>
204
-
<a href="/login" class="underline">login</a>
205
-
to add to the discussion
174
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white">
175
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
176
+
<a href="/login" class="underline">login</a> to join the discussion
206
177
</div>
207
178
{{ end }}
208
179
</div>
+34
-59
appview/pages/templates/repo/pulls/pulls.html
+34
-59
appview/pages/templates/repo/pulls/pulls.html
···
8
8
{{ end }}
9
9
10
10
{{ define "repoContent" }}
11
-
{{ $active := "closed" }}
12
-
{{ if .FilteringBy.IsOpen }}
13
-
{{ $active = "open" }}
14
-
{{ else if .FilteringBy.IsMerged }}
15
-
{{ $active = "merged" }}
16
-
{{ end }}
17
-
{{ $open :=
18
-
(dict
19
-
"Key" "open"
20
-
"Value" "open"
21
-
"Icon" "git-pull-request"
22
-
"Meta" (string .RepoInfo.Stats.PullCount.Open)) }}
23
-
{{ $merged :=
24
-
(dict
25
-
"Key" "merged"
26
-
"Value" "merged"
27
-
"Icon" "git-merge"
28
-
"Meta" (string .RepoInfo.Stats.PullCount.Merged)) }}
29
-
{{ $closed :=
30
-
(dict
31
-
"Key" "closed"
32
-
"Value" "closed"
33
-
"Icon" "ban"
34
-
"Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }}
35
-
{{ $values := list $open $merged $closed }}
36
-
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
37
-
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
38
-
<input type="hidden" name="state" value="{{ .FilteringBy.String }}">
39
-
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
40
-
{{ i "search" "w-4 h-4" }}
41
-
</div>
42
-
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
43
-
<a
44
-
href="?state={{ .FilteringBy.String }}"
45
-
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
46
-
>
47
-
{{ i "x" "w-4 h-4" }}
48
-
</a>
49
-
</form>
50
-
<div class="sm:row-start-1">
51
-
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
11
+
<div class="flex justify-between items-center">
12
+
<div class="flex gap-4">
13
+
<a
14
+
href="?state=open"
15
+
class="flex items-center gap-2 {{ if .FilteringBy.IsOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
16
+
>
17
+
{{ i "git-pull-request" "w-4 h-4" }}
18
+
<span>{{ .RepoInfo.Stats.PullCount.Open }} open</span>
19
+
</a>
20
+
<a
21
+
href="?state=merged"
22
+
class="flex items-center gap-2 {{ if .FilteringBy.IsMerged }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
23
+
>
24
+
{{ i "git-merge" "w-4 h-4" }}
25
+
<span>{{ .RepoInfo.Stats.PullCount.Merged }} merged</span>
26
+
</a>
27
+
<a
28
+
href="?state=closed"
29
+
class="flex items-center gap-2 {{ if .FilteringBy.IsClosed }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
30
+
>
31
+
{{ i "ban" "w-4 h-4" }}
32
+
<span>{{ .RepoInfo.Stats.PullCount.Closed }} closed</span>
33
+
</a>
34
+
</div>
35
+
<a
36
+
href="/{{ .RepoInfo.FullName }}/pulls/new"
37
+
class="btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
38
+
>
39
+
{{ i "git-pull-request-create" "w-4 h-4" }}
40
+
<span>new</span>
41
+
</a>
52
42
</div>
53
-
<a
54
-
href="/{{ .RepoInfo.FullName }}/pulls/new"
55
-
class="col-start-3 btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
56
-
>
57
-
{{ i "git-pull-request-create" "w-4 h-4" }}
58
-
<span>new</span>
59
-
</a>
60
-
</div>
61
-
<div class="error" id="pulls"></div>
43
+
<div class="error" id="pulls"></div>
62
44
{{ end }}
63
45
64
46
{{ define "repoAfter" }}
···
126
108
<span class="before:content-['·']"></span>
127
109
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
128
110
{{ end }}
129
-
130
-
{{ $state := .Labels }}
131
-
{{ range $k, $d := $.LabelDefs }}
132
-
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
133
-
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
134
-
{{ end }}
135
-
{{ end }}
136
111
</div>
137
112
</div>
138
113
{{ if .StackId }}
···
151
126
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
152
127
</div>
153
128
</summary>
154
-
{{ block "stackedPullList" (list $otherPulls $) }} {{ end }}
129
+
{{ block "pullList" (list $otherPulls $) }} {{ end }}
155
130
</details>
156
131
{{ end }}
157
132
{{ end }}
···
160
135
</div>
161
136
{{ end }}
162
137
163
-
{{ define "stackedPullList" }}
138
+
{{ define "pullList" }}
164
139
{{ $list := index . 0 }}
165
140
{{ $root := index . 1 }}
166
141
<div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
+8
-17
appview/pages/templates/repo/settings/access.html
+8
-17
appview/pages/templates/repo/settings/access.html
···
66
66
<div
67
67
id="add-collaborator-modal"
68
68
popover
69
-
class="
70
-
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700
71
-
dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
72
-
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
69
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
73
70
{{ template "addCollaboratorModal" . }}
74
71
</div>
75
72
{{ end }}
···
85
82
ADD COLLABORATOR
86
83
</label>
87
84
<p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p>
88
-
<actor-typeahead>
89
-
<input
90
-
autocapitalize="none"
91
-
autocorrect="off"
92
-
autocomplete="off"
93
-
type="text"
94
-
id="add-collaborator"
95
-
name="collaborator"
96
-
required
97
-
placeholder="user.tngl.sh"
98
-
class="w-full"
99
-
/>
100
-
</actor-typeahead>
85
+
<input
86
+
type="text"
87
+
id="add-collaborator"
88
+
name="collaborator"
89
+
required
90
+
placeholder="@foo.bsky.social"
91
+
/>
101
92
<div class="flex gap-2 pt-2">
102
93
<button
103
94
type="button"
-47
appview/pages/templates/repo/settings/general.html
-47
appview/pages/templates/repo/settings/general.html
···
6
6
{{ template "repo/settings/fragments/sidebar" . }}
7
7
</div>
8
8
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
-
{{ template "baseSettings" . }}
10
9
{{ template "branchSettings" . }}
11
10
{{ template "defaultLabelSettings" . }}
12
11
{{ template "customLabelSettings" . }}
···
14
13
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
15
14
</div>
16
15
</section>
17
-
{{ end }}
18
-
19
-
{{ define "baseSettings" }}
20
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/base" hx-swap="none">
21
-
<fieldset
22
-
class=""
23
-
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}
24
-
>
25
-
<h2 class="text-sm pb-2 uppercase font-bold">Description</h2>
26
-
<textarea
27
-
rows="3"
28
-
class="w-full mb-2"
29
-
id="base-form-description"
30
-
name="description"
31
-
>{{ .RepoInfo.Description }}</textarea>
32
-
<h2 class="text-sm pb-2 uppercase font-bold">Website URL</h2>
33
-
<input
34
-
type="text"
35
-
class="w-full mb-2"
36
-
id="base-form-website"
37
-
name="website"
38
-
value="{{ .RepoInfo.Website }}"
39
-
>
40
-
<h2 class="text-sm pb-2 uppercase font-bold">Topics</h2>
41
-
<p class="text-gray-500 dark:text-gray-400">
42
-
List of topics separated by spaces.
43
-
</p>
44
-
<textarea
45
-
rows="2"
46
-
class="w-full my-2"
47
-
id="base-form-topics"
48
-
name="topics"
49
-
>{{ range $topic := .RepoInfo.Topics }}{{ $topic }} {{ end }}</textarea>
50
-
<div id="repo-base-settings-error" class="text-red-500 dark:text-red-400"></div>
51
-
<div class="flex justify-end pt-2">
52
-
<button
53
-
type="submit"
54
-
class="btn-create flex items-center gap-2 group"
55
-
>
56
-
{{ i "save" "w-4 h-4" }}
57
-
save
58
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
59
-
</button>
60
-
</div>
61
-
<fieldset>
62
-
</form>
63
16
{{ end }}
64
17
65
18
{{ define "branchSettings" }}
-8
appview/pages/templates/repo/tree.html
-8
appview/pages/templates/repo/tree.html
···
59
59
{{ $icon := "folder" }}
60
60
{{ $iconStyle := "size-4 fill-current" }}
61
61
62
-
{{ if .IsSubmodule }}
63
-
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
64
-
{{ $icon = "folder-input" }}
65
-
{{ $iconStyle = "size-4" }}
66
-
{{ end }}
67
-
68
62
{{ if .IsFile }}
69
-
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
70
63
{{ $icon = "file" }}
71
64
{{ $iconStyle = "size-4" }}
72
65
{{ end }}
73
-
74
66
<a href="{{ $link }}" class="{{ $linkstyle }}">
75
67
<div class="flex items-center gap-2">
76
68
{{ i $icon $iconStyle "flex-shrink-0" }}
+8
-16
appview/pages/templates/spindles/fragments/addMemberModal.html
+8
-16
appview/pages/templates/spindles/fragments/addMemberModal.html
···
13
13
<div
14
14
id="add-member-{{ .Instance }}"
15
15
popover
16
-
class="
17
-
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
18
-
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
16
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
19
17
{{ block "addSpindleMemberPopover" . }} {{ end }}
20
18
</div>
21
19
{{ end }}
···
31
29
ADD MEMBER
32
30
</label>
33
31
<p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p>
34
-
<actor-typeahead>
35
-
<input
36
-
autocapitalize="none"
37
-
autocorrect="off"
38
-
autocomplete="off"
39
-
type="text"
40
-
id="member-did-{{ .Id }}"
41
-
name="member"
42
-
required
43
-
placeholder="user.tngl.sh"
44
-
class="w-full"
45
-
/>
46
-
</actor-typeahead>
32
+
<input
33
+
type="text"
34
+
id="member-did-{{ .Id }}"
35
+
name="member"
36
+
required
37
+
placeholder="@foo.bsky.social"
38
+
/>
47
39
<div class="flex gap-2 pt-2">
48
40
<button
49
41
type="button"
+3
-3
appview/pages/templates/strings/string.html
+3
-3
appview/pages/templates/strings/string.html
···
47
47
</span>
48
48
</section>
49
49
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
50
-
<div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
50
+
<div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
51
51
<span>
52
52
{{ .String.Filename }}
53
53
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
···
75
75
</div>
76
76
<div class="overflow-x-auto overflow-y-hidden relative">
77
77
{{ if .ShowRendered }}
78
-
<div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div>
78
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
79
79
{{ else }}
80
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .String.Contents .String.Filename | escapeHtml }}</div>
80
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
81
81
{{ end }}
82
82
</div>
83
83
{{ template "fragments/multiline-select" }}
-30
appview/pages/templates/timeline/fragments/goodfirstissues.html
-30
appview/pages/templates/timeline/fragments/goodfirstissues.html
···
1
-
{{ define "timeline/fragments/goodfirstissues" }}
2
-
{{ if .GfiLabel }}
3
-
<a href="/goodfirstissues" class="no-underline hover:no-underline">
4
-
<div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 ">
5
-
<div class="flex-1 flex flex-col gap-2">
6
-
<div class="text-purple-500 dark:text-purple-400">Oct 2025</div>
7
-
<p>
8
-
Make your first contribution to an open-source project this October.
9
-
<em>good-first-issue</em> helps new contributors find easy ways to
10
-
start contributing to open-source projects.
11
-
</p>
12
-
<span class="flex items-center gap-2 text-purple-500 dark:text-purple-400">
13
-
Browse issues {{ i "arrow-right" "size-4" }}
14
-
</span>
15
-
</div>
16
-
<div class="hidden md:block relative px-16 scale-150">
17
-
<div class="relative opacity-60">
18
-
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
19
-
</div>
20
-
<div class="relative -mt-4 ml-2 opacity-80">
21
-
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
22
-
</div>
23
-
<div class="relative -mt-4 ml-4">
24
-
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
25
-
</div>
26
-
</div>
27
-
</div>
28
-
</a>
29
-
{{ end }}
30
-
{{ end }}
+2
-2
appview/pages/templates/timeline/fragments/hero.html
+2
-2
appview/pages/templates/timeline/fragments/hero.html
···
4
4
<h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1>
5
5
6
6
<p class="text-lg">
7
-
Tangled is a decentralized Git hosting and collaboration platform.
7
+
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
8
8
</p>
9
9
<p class="text-lg">
10
-
We envision a place where developers have complete ownership of their
10
+
we envision a place where developers have complete ownership of their
11
11
code, open source communities can freely self-govern and most
12
12
importantly, coding can be social and fun again.
13
13
</p>
-1
appview/pages/templates/timeline/home.html
-1
appview/pages/templates/timeline/home.html
···
12
12
<div class="flex flex-col gap-4">
13
13
{{ template "timeline/fragments/hero" . }}
14
14
{{ template "features" . }}
15
-
{{ template "timeline/fragments/goodfirstissues" . }}
16
15
{{ template "timeline/fragments/trending" . }}
17
16
{{ template "timeline/fragments/timeline" . }}
18
17
<div class="flex justify-end">
-1
appview/pages/templates/timeline/timeline.html
-1
appview/pages/templates/timeline/timeline.html
-1
appview/pages/templates/user/completeSignup.html
-1
appview/pages/templates/user/completeSignup.html
-11
appview/pages/templates/user/fragments/editBio.html
-11
appview/pages/templates/user/fragments/editBio.html
···
20
20
</div>
21
21
22
22
<div class="flex flex-col gap-1">
23
-
<label class="m-0 p-0" for="pronouns">pronouns</label>
24
-
<div class="flex items-center gap-2 w-full">
25
-
{{ $pronouns := "" }}
26
-
{{ if and .Profile .Profile.Pronouns }}
27
-
{{ $pronouns = .Profile.Pronouns }}
28
-
{{ end }}
29
-
<input type="text" class="py-1 px-1 w-full" name="pronouns" value="{{ $pronouns }}">
30
-
</div>
31
-
</div>
32
-
33
-
<div class="flex flex-col gap-1">
34
23
<label class="m-0 p-0" for="location">location</label>
35
24
<div class="flex items-center gap-2 w-full">
36
25
{{ $location := "" }}
+1
-1
appview/pages/templates/user/fragments/followCard.html
+1
-1
appview/pages/templates/user/fragments/followCard.html
···
3
3
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
4
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
-
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" />
6
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
7
7
</div>
8
8
9
9
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
+6
-19
appview/pages/templates/user/fragments/profileCard.html
+6
-19
appview/pages/templates/user/fragments/profileCard.html
···
12
12
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
13
13
{{ $userIdent }}
14
14
</p>
15
-
{{ with .Profile }}
16
-
{{ if .Pronouns }}
17
-
<p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p>
18
-
{{ end }}
19
-
{{ end }}
15
+
<a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a>
20
16
</div>
21
17
22
18
<div class="md:hidden">
···
71
67
{{ end }}
72
68
</div>
73
69
{{ end }}
74
-
75
-
<div class="flex mt-2 items-center gap-2">
76
-
{{ if ne .FollowStatus.String "IsSelf" }}
77
-
{{ template "user/fragments/follow" . }}
78
-
{{ else }}
70
+
{{ if ne .FollowStatus.String "IsSelf" }}
71
+
{{ template "user/fragments/follow" . }}
72
+
{{ else }}
79
73
<button id="editBtn"
80
-
class="btn w-full flex items-center gap-2 group"
74
+
class="btn mt-2 w-full flex items-center gap-2 group"
81
75
hx-target="#profile-bio"
82
76
hx-get="/profile/edit-bio"
83
77
hx-swap="innerHTML">
···
85
79
edit
86
80
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
87
81
</button>
88
-
{{ end }}
89
-
90
-
<a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
91
-
href="/{{ $userIdent }}/feed.atom">
92
-
{{ i "rss" "size-4" }}
93
-
</a>
94
-
</div>
95
-
82
+
{{ end }}
96
83
</div>
97
84
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
98
85
</div>
+2
-24
appview/pages/templates/user/login.html
+2
-24
appview/pages/templates/user/login.html
···
8
8
<meta property="og:url" content="https://tangled.org/login" />
9
9
<meta property="og:description" content="login to for tangled" />
10
10
<script src="/static/htmx.min.js"></script>
11
-
<link rel="manifest" href="/pwa-manifest.json" />
12
11
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
12
<title>login · tangled</title>
14
13
</head>
15
14
<body class="flex items-center justify-center min-h-screen">
16
-
<main class="max-w-md px-7 mt-4">
15
+
<main class="max-w-md px-6 -mt-4">
17
16
<h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" >
18
17
{{ template "fragments/logotype" }}
19
18
</h1>
···
21
20
tightly-knit social coding.
22
21
</h2>
23
22
<form
24
-
class="mt-4"
23
+
class="mt-4 max-w-sm mx-auto"
25
24
hx-post="/login"
26
25
hx-swap="none"
27
26
hx-disabled-elt="#login-button"
···
29
28
<div class="flex flex-col">
30
29
<label for="handle">handle</label>
31
30
<input
32
-
autocapitalize="none"
33
-
autocorrect="off"
34
-
autocomplete="username"
35
31
type="text"
36
32
id="handle"
37
33
name="handle"
···
56
52
<span>login</span>
57
53
</button>
58
54
</form>
59
-
{{ if .ErrorCode }}
60
-
<div class="flex gap-2 my-2 bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-3 py-2 text-red-500 dark:text-red-300">
61
-
<span class="py-1">{{ i "circle-alert" "w-4 h-4" }}</span>
62
-
<div>
63
-
<h5 class="font-medium">Login error</h5>
64
-
<p class="text-sm">
65
-
{{ if eq .ErrorCode "access_denied" }}
66
-
You have not authorized the app.
67
-
{{ else if eq .ErrorCode "session" }}
68
-
Server failed to create user session.
69
-
{{ else }}
70
-
Internal Server error.
71
-
{{ end }}
72
-
Please try again.
73
-
</p>
74
-
</div>
75
-
</div>
76
-
{{ end }}
77
55
<p class="text-sm text-gray-500">
78
56
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
79
57
</p>
-14
appview/pages/templates/user/settings/notifications.html
-14
appview/pages/templates/user/settings/notifications.html
···
144
144
<div class="flex items-center justify-between p-2">
145
145
<div class="flex items-center gap-2">
146
146
<div class="flex flex-col gap-1">
147
-
<span class="font-bold">Mentions</span>
148
-
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
149
-
<span>When someone mentions you.</span>
150
-
</div>
151
-
</div>
152
-
</div>
153
-
<label class="flex items-center gap-2">
154
-
<input type="checkbox" name="mentioned" {{if .Preferences.UserMentioned}}checked{{end}}>
155
-
</label>
156
-
</div>
157
-
158
-
<div class="flex items-center justify-between p-2">
159
-
<div class="flex items-center gap-2">
160
-
<div class="flex flex-col gap-1">
161
147
<span class="font-bold">Email notifications</span>
162
148
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
163
149
<span>Receive notifications via email in addition to in-app notifications.</span>
+3
-1
appview/pages/templates/user/settings/profile.html
+3
-1
appview/pages/templates/user/settings/profile.html
···
33
33
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
34
34
<span>Handle</span>
35
35
</div>
36
+
{{ if .LoggedInUser.Handle }}
36
37
<span class="font-bold">
37
-
{{ resolve .LoggedInUser.Did }}
38
+
@{{ .LoggedInUser.Handle }}
38
39
</span>
40
+
{{ end }}
39
41
</div>
40
42
</div>
41
43
<div class="flex items-center justify-between p-4">
-1
appview/pages/templates/user/signup.html
-1
appview/pages/templates/user/signup.html
···
8
8
<meta property="og:url" content="https://tangled.org/signup" />
9
9
<meta property="og:description" content="sign up for tangled" />
10
10
<script src="/static/htmx.min.js"></script>
11
-
<link rel="manifest" href="/pwa-manifest.json" />
12
11
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
12
<title>sign up · tangled</title>
14
13
-46
appview/pagination/page.go
-46
appview/pagination/page.go
···
1
1
package pagination
2
2
3
-
import "context"
4
-
5
3
type Page struct {
6
4
Offset int // where to start from
7
5
Limit int // number of items in a page
···
12
10
Offset: 0,
13
11
Limit: 30,
14
12
}
15
-
}
16
-
17
-
type ctxKey struct{}
18
-
19
-
func IntoContext(ctx context.Context, page Page) context.Context {
20
-
return context.WithValue(ctx, ctxKey{}, page)
21
-
}
22
-
23
-
func FromContext(ctx context.Context) Page {
24
-
if ctx == nil {
25
-
return FirstPage()
26
-
}
27
-
v := ctx.Value(ctxKey{})
28
-
if v == nil {
29
-
return FirstPage()
30
-
}
31
-
page, ok := v.(Page)
32
-
if !ok {
33
-
return FirstPage()
34
-
}
35
-
return page
36
13
}
37
14
38
15
func (p Page) Previous() Page {
···
52
29
Limit: p.Limit,
53
30
}
54
31
}
55
-
56
-
func IterateAll[T any](
57
-
fetch func(page Page) ([]T, error),
58
-
handle func(items []T) error,
59
-
) error {
60
-
page := FirstPage()
61
-
for {
62
-
items, err := fetch(page)
63
-
if err != nil {
64
-
return err
65
-
}
66
-
67
-
err = handle(items)
68
-
if err != nil {
69
-
return err
70
-
}
71
-
if len(items) < page.Limit {
72
-
break
73
-
}
74
-
page = page.Next()
75
-
}
76
-
return nil
77
-
}
+17
-37
appview/pipelines/pipelines.go
+17
-37
appview/pipelines/pipelines.go
···
16
16
"tangled.org/core/appview/reporesolver"
17
17
"tangled.org/core/eventconsumer"
18
18
"tangled.org/core/idresolver"
19
+
"tangled.org/core/log"
19
20
"tangled.org/core/rbac"
20
21
spindlemodel "tangled.org/core/spindle/models"
21
22
···
35
36
logger *slog.Logger
36
37
}
37
38
38
-
func (p *Pipelines) Router() http.Handler {
39
-
r := chi.NewRouter()
40
-
r.Get("/", p.Index)
41
-
r.Get("/{pipeline}/workflow/{workflow}", p.Workflow)
42
-
r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs)
43
-
44
-
return r
45
-
}
46
-
47
39
func New(
48
40
oauth *oauth.OAuth,
49
41
repoResolver *reporesolver.RepoResolver,
···
53
45
db *db.DB,
54
46
config *config.Config,
55
47
enforcer *rbac.Enforcer,
56
-
logger *slog.Logger,
57
48
) *Pipelines {
58
-
return &Pipelines{
59
-
oauth: oauth,
49
+
logger := log.New("pipelines")
50
+
51
+
return &Pipelines{oauth: oauth,
60
52
repoResolver: repoResolver,
61
53
pages: pages,
62
54
idResolver: idResolver,
···
236
228
// start a goroutine to read from spindle
237
229
go readLogs(spindleConn, evChan)
238
230
239
-
stepStartTimes := make(map[int]time.Time)
231
+
stepIdx := 0
240
232
var fragment bytes.Buffer
241
233
for {
242
234
select {
···
268
260
269
261
switch logLine.Kind {
270
262
case spindlemodel.LogKindControl:
271
-
switch logLine.StepStatus {
272
-
case spindlemodel.StepStatusStart:
273
-
stepStartTimes[logLine.StepId] = logLine.Time
274
-
collapsed := false
275
-
if logLine.StepKind == spindlemodel.StepKindSystem {
276
-
collapsed = true
277
-
}
278
-
err = p.pages.LogBlock(&fragment, pages.LogBlockParams{
279
-
Id: logLine.StepId,
280
-
Name: logLine.Content,
281
-
Command: logLine.StepCommand,
282
-
Collapsed: collapsed,
283
-
StartTime: logLine.Time,
284
-
})
285
-
case spindlemodel.StepStatusEnd:
286
-
startTime := stepStartTimes[logLine.StepId]
287
-
endTime := logLine.Time
288
-
err = p.pages.LogBlockEnd(&fragment, pages.LogBlockEndParams{
289
-
Id: logLine.StepId,
290
-
StartTime: startTime,
291
-
EndTime: endTime,
292
-
})
263
+
// control messages create a new step block
264
+
stepIdx++
265
+
collapsed := false
266
+
if logLine.StepKind == spindlemodel.StepKindSystem {
267
+
collapsed = true
293
268
}
294
-
269
+
err = p.pages.LogBlock(&fragment, pages.LogBlockParams{
270
+
Id: stepIdx,
271
+
Name: logLine.Content,
272
+
Command: logLine.StepCommand,
273
+
Collapsed: collapsed,
274
+
})
295
275
case spindlemodel.LogKindData:
296
276
// data messages simply insert new log lines into current step
297
277
err = p.pages.LogLine(&fragment, pages.LogLineParams{
298
-
Id: logLine.StepId,
278
+
Id: stepIdx,
299
279
Content: logLine.Content,
300
280
})
301
281
}
+17
appview/pipelines/router.go
+17
appview/pipelines/router.go
···
1
+
package pipelines
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
"github.com/go-chi/chi/v5"
7
+
"tangled.org/core/appview/middleware"
8
+
)
9
+
10
+
func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
11
+
r := chi.NewRouter()
12
+
r.Get("/", p.Index)
13
+
r.Get("/{pipeline}/workflow/{workflow}", p.Workflow)
14
+
r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs)
15
+
16
+
return r
17
+
}
-321
appview/pulls/opengraph.go
-321
appview/pulls/opengraph.go
···
1
-
package pulls
2
-
3
-
import (
4
-
"bytes"
5
-
"context"
6
-
"fmt"
7
-
"image"
8
-
"image/color"
9
-
"image/png"
10
-
"log"
11
-
"net/http"
12
-
13
-
"tangled.org/core/appview/db"
14
-
"tangled.org/core/appview/models"
15
-
"tangled.org/core/appview/ogcard"
16
-
"tangled.org/core/patchutil"
17
-
"tangled.org/core/types"
18
-
)
19
-
20
-
func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, commentCount int, diffStats types.DiffStat, filesChanged int) (*ogcard.Card, error) {
21
-
width, height := ogcard.DefaultSize()
22
-
mainCard, err := ogcard.NewCard(width, height)
23
-
if err != nil {
24
-
return nil, err
25
-
}
26
-
27
-
// Split: content area (75%) and status/stats area (25%)
28
-
contentCard, statsArea := mainCard.Split(false, 75)
29
-
30
-
// Add padding to content
31
-
contentCard.SetMargin(50)
32
-
33
-
// Split content horizontally: main content (80%) and avatar area (20%)
34
-
mainContent, avatarArea := contentCard.Split(true, 80)
35
-
36
-
// Add margin to main content
37
-
mainContent.SetMargin(10)
38
-
39
-
// Use full main content area for repo name and title
40
-
bounds := mainContent.Img.Bounds()
41
-
startX := bounds.Min.X + mainContent.Margin
42
-
startY := bounds.Min.Y + mainContent.Margin
43
-
44
-
// Draw full repository name at top (owner/repo format)
45
-
var repoOwner string
46
-
owner, err := s.idResolver.ResolveIdent(context.Background(), repo.Did)
47
-
if err != nil {
48
-
repoOwner = repo.Did
49
-
} else {
50
-
repoOwner = "@" + owner.Handle.String()
51
-
}
52
-
53
-
fullRepoName := repoOwner + " / " + repo.Name
54
-
if len(fullRepoName) > 60 {
55
-
fullRepoName = fullRepoName[:60] + "…"
56
-
}
57
-
58
-
grayColor := color.RGBA{88, 96, 105, 255}
59
-
err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left)
60
-
if err != nil {
61
-
return nil, err
62
-
}
63
-
64
-
// Draw pull request title below repo name with wrapping
65
-
titleY := startY + 60
66
-
titleX := startX
67
-
68
-
// Truncate title if too long
69
-
pullTitle := pull.Title
70
-
maxTitleLength := 80
71
-
if len(pullTitle) > maxTitleLength {
72
-
pullTitle = pullTitle[:maxTitleLength] + "…"
73
-
}
74
-
75
-
// Create a temporary card for the title area to enable wrapping
76
-
titleBounds := mainContent.Img.Bounds()
77
-
titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin
78
-
titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for pull ID
79
-
80
-
titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight)
81
-
titleCard := &ogcard.Card{
82
-
Img: mainContent.Img.SubImage(titleRect).(*image.RGBA),
83
-
Font: mainContent.Font,
84
-
Margin: 0,
85
-
}
86
-
87
-
// Draw wrapped title
88
-
lines, err := titleCard.DrawText(pullTitle, color.Black, 54, ogcard.Top, ogcard.Left)
89
-
if err != nil {
90
-
return nil, err
91
-
}
92
-
93
-
// Calculate where title ends (number of lines * line height)
94
-
lineHeight := 60 // Approximate line height for 54pt font
95
-
titleEndY := titleY + (len(lines) * lineHeight) + 10
96
-
97
-
// Draw pull ID in gray below the title
98
-
pullIdText := fmt.Sprintf("#%d", pull.PullId)
99
-
err = mainContent.DrawTextAt(pullIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left)
100
-
if err != nil {
101
-
return nil, err
102
-
}
103
-
104
-
// Get pull author handle (needed for avatar and metadata)
105
-
var authorHandle string
106
-
author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid)
107
-
if err != nil {
108
-
authorHandle = pull.OwnerDid
109
-
} else {
110
-
authorHandle = "@" + author.Handle.String()
111
-
}
112
-
113
-
// Draw avatar circle on the right side
114
-
avatarBounds := avatarArea.Img.Bounds()
115
-
avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
116
-
if avatarSize > 220 {
117
-
avatarSize = 220
118
-
}
119
-
avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
120
-
avatarY := avatarBounds.Min.Y + 20
121
-
122
-
// Get avatar URL for pull author
123
-
avatarURL := s.pages.AvatarUrl(authorHandle, "256")
124
-
err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
125
-
if err != nil {
126
-
log.Printf("failed to draw avatar (non-fatal): %v", err)
127
-
}
128
-
129
-
// Split stats area: left side for status/stats (80%), right side for dolly (20%)
130
-
statusStatsArea, dollyArea := statsArea.Split(true, 80)
131
-
132
-
// Draw status and stats
133
-
statsBounds := statusStatsArea.Img.Bounds()
134
-
statsX := statsBounds.Min.X + 60 // left padding
135
-
statsY := statsBounds.Min.Y
136
-
137
-
iconColor := color.RGBA{88, 96, 105, 255}
138
-
iconSize := 36
139
-
textSize := 36.0
140
-
labelSize := 28.0
141
-
iconBaselineOffset := int(textSize) / 2
142
-
143
-
// Draw status (open/merged/closed) with colored icon and text
144
-
var statusIcon string
145
-
var statusText string
146
-
var statusColor color.RGBA
147
-
148
-
if pull.State.IsOpen() {
149
-
statusIcon = "git-pull-request"
150
-
statusText = "open"
151
-
statusColor = color.RGBA{34, 139, 34, 255} // green
152
-
} else if pull.State.IsMerged() {
153
-
statusIcon = "git-merge"
154
-
statusText = "merged"
155
-
statusColor = color.RGBA{138, 43, 226, 255} // purple
156
-
} else {
157
-
statusIcon = "git-pull-request-closed"
158
-
statusText = "closed"
159
-
statusColor = color.RGBA{128, 128, 128, 255} // gray
160
-
}
161
-
162
-
statusIconSize := 36
163
-
164
-
// Draw icon with status color
165
-
err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor)
166
-
if err != nil {
167
-
log.Printf("failed to draw status icon: %v", err)
168
-
}
169
-
170
-
// Draw text with status color
171
-
textX := statsX + statusIconSize + 12
172
-
statusTextSize := 32.0
173
-
err = statusStatsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusColor, statusTextSize, ogcard.Middle, ogcard.Left)
174
-
if err != nil {
175
-
log.Printf("failed to draw status text: %v", err)
176
-
}
177
-
178
-
statusTextWidth := len(statusText) * 20
179
-
currentX := statsX + statusIconSize + 12 + statusTextWidth + 40
180
-
181
-
// Draw comment count
182
-
err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
183
-
if err != nil {
184
-
log.Printf("failed to draw comment icon: %v", err)
185
-
}
186
-
187
-
currentX += iconSize + 15
188
-
commentText := fmt.Sprintf("%d comments", commentCount)
189
-
if commentCount == 1 {
190
-
commentText = "1 comment"
191
-
}
192
-
err = statusStatsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
193
-
if err != nil {
194
-
log.Printf("failed to draw comment text: %v", err)
195
-
}
196
-
197
-
commentTextWidth := len(commentText) * 20
198
-
currentX += commentTextWidth + 40
199
-
200
-
// Draw files changed
201
-
err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
202
-
if err != nil {
203
-
log.Printf("failed to draw file diff icon: %v", err)
204
-
}
205
-
206
-
currentX += iconSize + 15
207
-
filesText := fmt.Sprintf("%d files", filesChanged)
208
-
if filesChanged == 1 {
209
-
filesText = "1 file"
210
-
}
211
-
err = statusStatsArea.DrawTextAt(filesText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
212
-
if err != nil {
213
-
log.Printf("failed to draw files text: %v", err)
214
-
}
215
-
216
-
filesTextWidth := len(filesText) * 20
217
-
currentX += filesTextWidth
218
-
219
-
// Draw additions (green +)
220
-
greenColor := color.RGBA{34, 139, 34, 255}
221
-
additionsText := fmt.Sprintf("+%d", diffStats.Insertions)
222
-
err = statusStatsArea.DrawTextAt(additionsText, currentX, statsY+iconBaselineOffset, greenColor, textSize, ogcard.Middle, ogcard.Left)
223
-
if err != nil {
224
-
log.Printf("failed to draw additions text: %v", err)
225
-
}
226
-
227
-
additionsTextWidth := len(additionsText) * 20
228
-
currentX += additionsTextWidth + 30
229
-
230
-
// Draw deletions (red -) right next to additions
231
-
redColor := color.RGBA{220, 20, 60, 255}
232
-
deletionsText := fmt.Sprintf("-%d", diffStats.Deletions)
233
-
err = statusStatsArea.DrawTextAt(deletionsText, currentX, statsY+iconBaselineOffset, redColor, textSize, ogcard.Middle, ogcard.Left)
234
-
if err != nil {
235
-
log.Printf("failed to draw deletions text: %v", err)
236
-
}
237
-
238
-
// Draw dolly logo on the right side
239
-
dollyBounds := dollyArea.Img.Bounds()
240
-
dollySize := 90
241
-
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
242
-
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
243
-
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
244
-
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
245
-
if err != nil {
246
-
log.Printf("dolly silhouette not available (this is ok): %v", err)
247
-
}
248
-
249
-
// Draw "opened by @author" and date at the bottom with more spacing
250
-
labelY := statsY + iconSize + 30
251
-
252
-
// Format the opened date
253
-
openedDate := pull.Created.Format("Jan 2, 2006")
254
-
metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
255
-
256
-
err = statusStatsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
257
-
if err != nil {
258
-
log.Printf("failed to draw metadata: %v", err)
259
-
}
260
-
261
-
return mainCard, nil
262
-
}
263
-
264
-
func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
265
-
f, err := s.repoResolver.Resolve(r)
266
-
if err != nil {
267
-
log.Println("failed to get repo and knot", err)
268
-
return
269
-
}
270
-
271
-
pull, ok := r.Context().Value("pull").(*models.Pull)
272
-
if !ok {
273
-
log.Println("pull not found in context")
274
-
http.Error(w, "pull not found", http.StatusNotFound)
275
-
return
276
-
}
277
-
278
-
// Get comment count from database
279
-
comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID))
280
-
if err != nil {
281
-
log.Printf("failed to get pull comments: %v", err)
282
-
}
283
-
commentCount := len(comments)
284
-
285
-
// Calculate diff stats from latest submission using patchutil
286
-
var diffStats types.DiffStat
287
-
filesChanged := 0
288
-
if len(pull.Submissions) > 0 {
289
-
latestSubmission := pull.Submissions[len(pull.Submissions)-1]
290
-
niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch)
291
-
diffStats.Insertions = int64(niceDiff.Stat.Insertions)
292
-
diffStats.Deletions = int64(niceDiff.Stat.Deletions)
293
-
filesChanged = niceDiff.Stat.FilesChanged
294
-
}
295
-
296
-
card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged)
297
-
if err != nil {
298
-
log.Println("failed to draw pull summary card", err)
299
-
http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
300
-
return
301
-
}
302
-
303
-
var imageBuffer bytes.Buffer
304
-
err = png.Encode(&imageBuffer, card.Img)
305
-
if err != nil {
306
-
log.Println("failed to encode pull summary card", err)
307
-
http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError)
308
-
return
309
-
}
310
-
311
-
imageBytes := imageBuffer.Bytes()
312
-
313
-
w.Header().Set("Content-Type", "image/png")
314
-
w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
315
-
w.WriteHeader(http.StatusOK)
316
-
_, err = w.Write(imageBytes)
317
-
if err != nil {
318
-
log.Println("failed to write pull summary card", err)
319
-
return
320
-
}
321
-
}
+196
-282
appview/pulls/pulls.go
+196
-282
appview/pulls/pulls.go
···
6
6
"errors"
7
7
"fmt"
8
8
"log"
9
-
"log/slog"
10
9
"net/http"
11
-
"slices"
12
10
"sort"
13
11
"strconv"
14
12
"strings"
···
17
15
"tangled.org/core/api/tangled"
18
16
"tangled.org/core/appview/config"
19
17
"tangled.org/core/appview/db"
20
-
pulls_indexer "tangled.org/core/appview/indexer/pulls"
21
18
"tangled.org/core/appview/models"
22
19
"tangled.org/core/appview/notify"
23
20
"tangled.org/core/appview/oauth"
24
21
"tangled.org/core/appview/pages"
25
22
"tangled.org/core/appview/pages/markup"
26
23
"tangled.org/core/appview/reporesolver"
27
-
"tangled.org/core/appview/validator"
28
24
"tangled.org/core/appview/xrpcclient"
29
25
"tangled.org/core/idresolver"
30
26
"tangled.org/core/patchutil"
31
-
"tangled.org/core/rbac"
32
27
"tangled.org/core/tid"
33
28
"tangled.org/core/types"
34
29
30
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
35
31
comatproto "github.com/bluesky-social/indigo/api/atproto"
36
-
"github.com/bluesky-social/indigo/atproto/syntax"
37
32
lexutil "github.com/bluesky-social/indigo/lex/util"
38
33
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
39
34
"github.com/go-chi/chi/v5"
···
48
43
db *db.DB
49
44
config *config.Config
50
45
notifier notify.Notifier
51
-
enforcer *rbac.Enforcer
52
-
logger *slog.Logger
53
-
validator *validator.Validator
54
-
indexer *pulls_indexer.Indexer
55
46
}
56
47
57
48
func New(
···
62
53
db *db.DB,
63
54
config *config.Config,
64
55
notifier notify.Notifier,
65
-
enforcer *rbac.Enforcer,
66
-
validator *validator.Validator,
67
-
indexer *pulls_indexer.Indexer,
68
-
logger *slog.Logger,
69
56
) *Pulls {
70
57
return &Pulls{
71
58
oauth: oauth,
···
75
62
db: db,
76
63
config: config,
77
64
notifier: notifier,
78
-
enforcer: enforcer,
79
-
logger: logger,
80
-
validator: validator,
81
-
indexer: indexer,
82
65
}
83
66
}
84
67
···
115
98
}
116
99
117
100
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
118
-
branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
119
101
resubmitResult := pages.Unknown
120
102
if user.Did == pull.OwnerDid {
121
103
resubmitResult = s.resubmitCheck(r, f, pull, stack)
122
104
}
123
105
124
106
s.pages.PullActionsFragment(w, pages.PullActionsParams{
125
-
LoggedInUser: user,
126
-
RepoInfo: f.RepoInfo(user),
127
-
Pull: pull,
128
-
RoundNumber: roundNumber,
129
-
MergeCheck: mergeCheckResponse,
130
-
ResubmitCheck: resubmitResult,
131
-
BranchDeleteStatus: branchDeleteStatus,
132
-
Stack: stack,
107
+
LoggedInUser: user,
108
+
RepoInfo: f.RepoInfo(user),
109
+
Pull: pull,
110
+
RoundNumber: roundNumber,
111
+
MergeCheck: mergeCheckResponse,
112
+
ResubmitCheck: resubmitResult,
113
+
Stack: stack,
133
114
})
134
115
return
135
116
}
···
154
135
stack, _ := r.Context().Value("stack").(models.Stack)
155
136
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
156
137
138
+
totalIdents := 1
139
+
for _, submission := range pull.Submissions {
140
+
totalIdents += len(submission.Comments)
141
+
}
142
+
143
+
identsToResolve := make([]string, totalIdents)
144
+
145
+
// populate idents
146
+
identsToResolve[0] = pull.OwnerDid
147
+
idx := 1
148
+
for _, submission := range pull.Submissions {
149
+
for _, comment := range submission.Comments {
150
+
identsToResolve[idx] = comment.OwnerDid
151
+
idx += 1
152
+
}
153
+
}
154
+
157
155
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
158
-
branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
159
156
resubmitResult := pages.Unknown
160
157
if user != nil && user.Did == pull.OwnerDid {
161
158
resubmitResult = s.resubmitCheck(r, f, pull, stack)
···
192
189
m[p.Sha] = p
193
190
}
194
191
195
-
reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri())
192
+
reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt())
196
193
if err != nil {
197
194
log.Println("failed to get pull reactions")
198
195
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
···
200
197
201
198
userReactions := map[models.ReactionKind]bool{}
202
199
if user != nil {
203
-
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri())
204
-
}
205
-
206
-
labelDefs, err := db.GetLabelDefinitions(
207
-
s.db,
208
-
db.FilterIn("at_uri", f.Repo.Labels),
209
-
db.FilterContains("scope", tangled.RepoPullNSID),
210
-
)
211
-
if err != nil {
212
-
log.Println("failed to fetch labels", err)
213
-
s.pages.Error503(w)
214
-
return
215
-
}
216
-
217
-
defs := make(map[string]*models.LabelDefinition)
218
-
for _, l := range labelDefs {
219
-
defs[l.AtUri().String()] = &l
200
+
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
220
201
}
221
202
222
203
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
223
-
LoggedInUser: user,
224
-
RepoInfo: repoInfo,
225
-
Pull: pull,
226
-
Stack: stack,
227
-
AbandonedPulls: abandonedPulls,
228
-
BranchDeleteStatus: branchDeleteStatus,
229
-
MergeCheck: mergeCheckResponse,
230
-
ResubmitCheck: resubmitResult,
231
-
Pipelines: m,
204
+
LoggedInUser: user,
205
+
RepoInfo: repoInfo,
206
+
Pull: pull,
207
+
Stack: stack,
208
+
AbandonedPulls: abandonedPulls,
209
+
MergeCheck: mergeCheckResponse,
210
+
ResubmitCheck: resubmitResult,
211
+
Pipelines: m,
232
212
233
213
OrderedReactionKinds: models.OrderedReactionKinds,
234
-
Reactions: reactionMap,
214
+
Reactions: reactionCountMap,
235
215
UserReacted: userReactions,
236
-
237
-
LabelDefs: defs,
238
216
})
239
217
}
240
218
···
305
283
return result
306
284
}
307
285
308
-
func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus {
309
-
if pull.State != models.PullMerged {
310
-
return nil
311
-
}
312
-
313
-
user := s.oauth.GetUser(r)
314
-
if user == nil {
315
-
return nil
316
-
}
317
-
318
-
var branch string
319
-
var repo *models.Repo
320
-
// check if the branch exists
321
-
// NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
322
-
if pull.IsBranchBased() {
323
-
branch = pull.PullSource.Branch
324
-
repo = &f.Repo
325
-
} else if pull.IsForkBased() {
326
-
branch = pull.PullSource.Branch
327
-
repo = pull.PullSource.Repo
328
-
} else {
329
-
return nil
330
-
}
331
-
332
-
// deleted fork
333
-
if repo == nil {
334
-
return nil
335
-
}
336
-
337
-
// user can only delete branch if they are a collaborator in the repo that the branch belongs to
338
-
perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo())
339
-
if !slices.Contains(perms, "repo:push") {
340
-
return nil
341
-
}
342
-
343
-
scheme := "http"
344
-
if !s.config.Core.Dev {
345
-
scheme = "https"
346
-
}
347
-
host := fmt.Sprintf("%s://%s", scheme, repo.Knot)
348
-
xrpcc := &indigoxrpc.Client{
349
-
Host: host,
350
-
}
351
-
352
-
resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name))
353
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
354
-
return nil
355
-
}
356
-
357
-
return &models.BranchDeleteStatus{
358
-
Repo: repo,
359
-
Branch: resp.Name,
360
-
}
361
-
}
362
-
363
286
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
364
287
if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
365
288
return pages.Unknown
···
407
330
408
331
targetBranch := branchResp
409
332
410
-
latestSourceRev := pull.LatestSha()
333
+
latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev
411
334
412
335
if pull.IsStacked() && stack != nil {
413
336
top := stack[0]
414
-
latestSourceRev = top.LatestSha()
337
+
latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev
415
338
}
416
339
417
340
if latestSourceRev != targetBranch.Hash {
···
451
374
return
452
375
}
453
376
454
-
patch := pull.Submissions[roundIdInt].CombinedPatch()
377
+
patch := pull.Submissions[roundIdInt].Patch
455
378
diff := patchutil.AsNiceDiff(patch, pull.TargetBranch)
456
379
457
380
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
···
502
425
return
503
426
}
504
427
505
-
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch())
428
+
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch)
506
429
if err != nil {
507
430
log.Println("failed to interdiff; current patch malformed")
508
431
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
509
432
return
510
433
}
511
434
512
-
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch())
435
+
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch)
513
436
if err != nil {
514
437
log.Println("failed to interdiff; previous patch malformed")
515
438
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
···
549
472
}
550
473
551
474
func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) {
552
-
l := s.logger.With("handler", "RepoPulls")
553
-
554
475
user := s.oauth.GetUser(r)
555
476
params := r.URL.Query()
556
477
···
568
489
return
569
490
}
570
491
571
-
keyword := params.Get("q")
572
-
573
-
var ids []int64
574
-
searchOpts := models.PullSearchOptions{
575
-
Keyword: keyword,
576
-
RepoAt: f.RepoAt().String(),
577
-
State: state,
578
-
// Page: page,
579
-
}
580
-
l.Debug("searching with", "searchOpts", searchOpts)
581
-
if keyword != "" {
582
-
res, err := s.indexer.Search(r.Context(), searchOpts)
583
-
if err != nil {
584
-
l.Error("failed to search for pulls", "err", err)
585
-
return
586
-
}
587
-
ids = res.Hits
588
-
l.Debug("searched pulls with indexer", "count", len(ids))
589
-
} else {
590
-
ids, err = db.GetPullIDs(s.db, searchOpts)
591
-
if err != nil {
592
-
l.Error("failed to get all pull ids", "err", err)
593
-
return
594
-
}
595
-
l.Debug("indexed all pulls from the db", "count", len(ids))
596
-
}
597
-
598
492
pulls, err := db.GetPulls(
599
493
s.db,
600
-
db.FilterIn("id", ids),
494
+
db.FilterEq("repo_at", f.RepoAt()),
495
+
db.FilterEq("state", state),
601
496
)
602
497
if err != nil {
603
498
log.Println("failed to get pulls", err)
···
662
557
m[p.Sha] = p
663
558
}
664
559
665
-
labelDefs, err := db.GetLabelDefinitions(
666
-
s.db,
667
-
db.FilterIn("at_uri", f.Repo.Labels),
668
-
db.FilterContains("scope", tangled.RepoPullNSID),
669
-
)
670
-
if err != nil {
671
-
log.Println("failed to fetch labels", err)
672
-
s.pages.Error503(w)
673
-
return
674
-
}
675
-
676
-
defs := make(map[string]*models.LabelDefinition)
677
-
for _, l := range labelDefs {
678
-
defs[l.AtUri().String()] = &l
679
-
}
680
-
681
560
s.pages.RepoPulls(w, pages.RepoPullsParams{
682
561
LoggedInUser: s.oauth.GetUser(r),
683
562
RepoInfo: f.RepoInfo(user),
684
563
Pulls: pulls,
685
-
LabelDefs: defs,
686
564
FilteringBy: state,
687
-
FilterQuery: keyword,
688
565
Stacks: stacks,
689
566
Pipelines: m,
690
567
})
691
568
}
692
569
693
570
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
694
-
l := s.logger.With("handler", "PullComment")
695
571
user := s.oauth.GetUser(r)
696
572
f, err := s.repoResolver.Resolve(r)
697
573
if err != nil {
···
741
617
742
618
createdAt := time.Now().Format(time.RFC3339)
743
619
620
+
pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId)
621
+
if err != nil {
622
+
log.Println("failed to get pull at", err)
623
+
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
624
+
return
625
+
}
626
+
744
627
client, err := s.oauth.AuthorizedClient(r)
745
628
if err != nil {
746
629
log.Println("failed to get authorized client", err)
747
630
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
748
631
return
749
632
}
750
-
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
633
+
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
751
634
Collection: tangled.RepoPullCommentNSID,
752
635
Repo: user.Did,
753
636
Rkey: tid.TID(),
754
637
Record: &lexutil.LexiconTypeDecoder{
755
638
Val: &tangled.RepoPullComment{
756
-
Pull: pull.AtUri().String(),
639
+
Pull: string(pullAt),
757
640
Body: body,
758
641
CreatedAt: createdAt,
759
642
},
···
789
672
return
790
673
}
791
674
792
-
rawMentions := markup.FindUserMentions(comment.Body)
793
-
idents := s.idResolver.ResolveIdents(r.Context(), rawMentions)
794
-
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
795
-
var mentions []syntax.DID
796
-
for _, ident := range idents {
797
-
if ident != nil && !ident.Handle.IsInvalidHandle() {
798
-
mentions = append(mentions, ident.DID)
799
-
}
800
-
}
801
-
s.notifier.NewPullComment(r.Context(), comment, mentions)
675
+
s.notifier.NewPullComment(r.Context(), comment)
802
676
803
677
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
804
678
return
···
1010
884
}
1011
885
1012
886
sourceRev := comparison.Rev2
1013
-
patch := comparison.FormatPatchRaw
1014
-
combined := comparison.CombinedPatchRaw
887
+
patch := comparison.Patch
1015
888
1016
-
if err := s.validator.ValidatePatch(&patch); err != nil {
1017
-
s.logger.Error("failed to validate patch", "err", err)
889
+
if !patchutil.IsPatchValid(patch) {
1018
890
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1019
891
return
1020
892
}
···
1027
899
Sha: comparison.Rev2,
1028
900
}
1029
901
1030
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
902
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
1031
903
}
1032
904
1033
905
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
1034
-
if err := s.validator.ValidatePatch(&patch); err != nil {
1035
-
s.logger.Error("patch validation failed", "err", err)
906
+
if !patchutil.IsPatchValid(patch) {
1036
907
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1037
908
return
1038
909
}
1039
910
1040
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked)
911
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked)
1041
912
}
1042
913
1043
914
func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
···
1120
991
}
1121
992
1122
993
sourceRev := comparison.Rev2
1123
-
patch := comparison.FormatPatchRaw
1124
-
combined := comparison.CombinedPatchRaw
994
+
patch := comparison.Patch
1125
995
1126
-
if err := s.validator.ValidatePatch(&patch); err != nil {
1127
-
s.logger.Error("failed to validate patch", "err", err)
996
+
if !patchutil.IsPatchValid(patch) {
1128
997
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1129
998
return
1130
999
}
···
1142
1011
Sha: sourceRev,
1143
1012
}
1144
1013
1145
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1014
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
1146
1015
}
1147
1016
1148
1017
func (s *Pulls) createPullRequest(
···
1152
1021
user *oauth.User,
1153
1022
title, body, targetBranch string,
1154
1023
patch string,
1155
-
combined string,
1156
1024
sourceRev string,
1157
1025
pullSource *models.PullSource,
1158
1026
recordPullSource *tangled.RepoPull_Source,
···
1190
1058
1191
1059
// We've already checked earlier if it's diff-based and title is empty,
1192
1060
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1193
-
if title == "" || body == "" {
1061
+
if title == "" {
1194
1062
formatPatches, err := patchutil.ExtractPatches(patch)
1195
1063
if err != nil {
1196
1064
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
···
1201
1069
return
1202
1070
}
1203
1071
1204
-
if title == "" {
1205
-
title = formatPatches[0].Title
1206
-
}
1207
-
if body == "" {
1208
-
body = formatPatches[0].Body
1209
-
}
1072
+
title = formatPatches[0].Title
1073
+
body = formatPatches[0].Body
1210
1074
}
1211
1075
1212
1076
rkey := tid.TID()
1213
1077
initialSubmission := models.PullSubmission{
1214
1078
Patch: patch,
1215
-
Combined: combined,
1216
1079
SourceRev: sourceRev,
1217
1080
}
1218
1081
pull := &models.Pull{
···
1240
1103
return
1241
1104
}
1242
1105
1243
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1106
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1244
1107
Collection: tangled.RepoPullNSID,
1245
1108
Repo: user.Did,
1246
1109
Rkey: rkey,
···
1251
1114
Repo: string(f.RepoAt()),
1252
1115
Branch: targetBranch,
1253
1116
},
1254
-
Patch: patch,
1255
-
Source: recordPullSource,
1256
-
CreatedAt: time.Now().Format(time.RFC3339),
1117
+
Patch: patch,
1118
+
Source: recordPullSource,
1257
1119
},
1258
1120
},
1259
1121
})
···
1338
1200
}
1339
1201
writes = append(writes, &write)
1340
1202
}
1341
-
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1203
+
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1342
1204
Repo: user.Did,
1343
1205
Writes: writes,
1344
1206
})
···
1388
1250
return
1389
1251
}
1390
1252
1391
-
if err := s.validator.ValidatePatch(&patch); err != nil {
1392
-
s.logger.Error("faield to validate patch", "err", err)
1253
+
if patch == "" || !patchutil.IsPatchValid(patch) {
1393
1254
s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1394
1255
return
1395
1256
}
···
1643
1504
1644
1505
patch := r.FormValue("patch")
1645
1506
1646
-
s.resubmitPullHelper(w, r, f, user, pull, patch, "", "")
1507
+
s.resubmitPullHelper(w, r, f, user, pull, patch, "")
1647
1508
}
1648
1509
1649
1510
func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
···
1704
1565
}
1705
1566
1706
1567
sourceRev := comparison.Rev2
1707
-
patch := comparison.FormatPatchRaw
1708
-
combined := comparison.CombinedPatchRaw
1568
+
patch := comparison.Patch
1709
1569
1710
-
s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1570
+
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1711
1571
}
1712
1572
1713
1573
func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
···
1739
1599
return
1740
1600
}
1741
1601
1602
+
// extract patch by performing compare
1603
+
forkScheme := "http"
1604
+
if !s.config.Core.Dev {
1605
+
forkScheme = "https"
1606
+
}
1607
+
forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1608
+
forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1609
+
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch)
1610
+
if err != nil {
1611
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1612
+
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1613
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1614
+
return
1615
+
}
1616
+
log.Printf("failed to compare branches: %s", err)
1617
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1618
+
return
1619
+
}
1620
+
1621
+
var forkComparison types.RepoFormatPatchResponse
1622
+
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1623
+
log.Println("failed to decode XRPC compare response for fork", err)
1624
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1625
+
return
1626
+
}
1627
+
1742
1628
// update the hidden tracking branch to latest
1743
1629
client, err := s.oauth.ServiceClient(
1744
1630
r,
···
1770
1656
return
1771
1657
}
1772
1658
1773
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1774
-
// extract patch by performing compare
1775
-
forkScheme := "http"
1776
-
if !s.config.Core.Dev {
1777
-
forkScheme = "https"
1778
-
}
1779
-
forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1780
-
forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1781
-
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch)
1782
-
if err != nil {
1783
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1784
-
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1785
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1786
-
return
1787
-
}
1788
-
log.Printf("failed to compare branches: %s", err)
1789
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1790
-
return
1791
-
}
1792
-
1793
-
var forkComparison types.RepoFormatPatchResponse
1794
-
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1795
-
log.Println("failed to decode XRPC compare response for fork", err)
1796
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1797
-
return
1798
-
}
1799
-
1800
1659
// Use the fork comparison we already made
1801
1660
comparison := forkComparison
1802
1661
1803
1662
sourceRev := comparison.Rev2
1804
-
patch := comparison.FormatPatchRaw
1805
-
combined := comparison.CombinedPatchRaw
1663
+
patch := comparison.Patch
1664
+
1665
+
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1666
+
}
1806
1667
1807
-
s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1668
+
// validate a resubmission against a pull request
1669
+
func validateResubmittedPatch(pull *models.Pull, patch string) error {
1670
+
if patch == "" {
1671
+
return fmt.Errorf("Patch is empty.")
1672
+
}
1673
+
1674
+
if patch == pull.LatestPatch() {
1675
+
return fmt.Errorf("Patch is identical to previous submission.")
1676
+
}
1677
+
1678
+
if !patchutil.IsPatchValid(patch) {
1679
+
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1680
+
}
1681
+
1682
+
return nil
1808
1683
}
1809
1684
1810
1685
func (s *Pulls) resubmitPullHelper(
···
1814
1689
user *oauth.User,
1815
1690
pull *models.Pull,
1816
1691
patch string,
1817
-
combined string,
1818
1692
sourceRev string,
1819
1693
) {
1820
1694
if pull.IsStacked() {
···
1823
1697
return
1824
1698
}
1825
1699
1826
-
if err := s.validator.ValidatePatch(&patch); err != nil {
1700
+
if err := validateResubmittedPatch(pull, patch); err != nil {
1827
1701
s.pages.Notice(w, "resubmit-error", err.Error())
1828
-
return
1829
-
}
1830
-
1831
-
if patch == pull.LatestPatch() {
1832
-
s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
1833
1702
return
1834
1703
}
1835
1704
1836
1705
// validate sourceRev if branch/fork based
1837
1706
if pull.IsBranchBased() || pull.IsForkBased() {
1838
-
if sourceRev == pull.LatestSha() {
1707
+
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1839
1708
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1840
1709
return
1841
1710
}
···
1849
1718
}
1850
1719
defer tx.Rollback()
1851
1720
1852
-
pullAt := pull.AtUri()
1853
-
newRoundNumber := len(pull.Submissions)
1854
-
newPatch := patch
1855
-
newSourceRev := sourceRev
1856
-
combinedPatch := combined
1857
-
err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
1721
+
err = db.ResubmitPull(tx, pull, patch, sourceRev)
1858
1722
if err != nil {
1859
1723
log.Println("failed to create pull request", err)
1860
1724
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
···
1867
1731
return
1868
1732
}
1869
1733
1870
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1734
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1871
1735
if err != nil {
1872
1736
// failed to get record
1873
1737
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
···
1890
1754
}
1891
1755
}
1892
1756
1893
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1757
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1894
1758
Collection: tangled.RepoPullNSID,
1895
1759
Repo: user.Did,
1896
1760
Rkey: pull.Rkey,
···
1902
1766
Repo: string(f.RepoAt()),
1903
1767
Branch: pull.TargetBranch,
1904
1768
},
1905
-
Patch: patch, // new patch
1906
-
Source: recordPullSource,
1907
-
CreatedAt: time.Now().Format(time.RFC3339),
1769
+
Patch: patch, // new patch
1770
+
Source: recordPullSource,
1908
1771
},
1909
1772
},
1910
1773
})
···
1955
1818
// commits that got deleted: corresponding pull is closed
1956
1819
// commits that got added: new pull is created
1957
1820
// commits that got updated: corresponding pull is resubmitted & new round begins
1821
+
//
1822
+
// for commits that were unchanged: no changes, parent-change-id is updated as necessary
1958
1823
additions := make(map[string]*models.Pull)
1959
1824
deletions := make(map[string]*models.Pull)
1825
+
unchanged := make(map[string]struct{})
1960
1826
updated := make(map[string]struct{})
1961
1827
1962
1828
// pulls in orignal stack but not in new one
···
1978
1844
for _, np := range newStack {
1979
1845
if op, ok := origById[np.ChangeId]; ok {
1980
1846
// pull exists in both stacks
1981
-
updated[op.ChangeId] = struct{}{}
1847
+
// TODO: can we avoid reparse?
1848
+
origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch()))
1849
+
newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch()))
1850
+
1851
+
origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr)
1852
+
newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr)
1853
+
1854
+
patchutil.SortPatch(newFiles)
1855
+
patchutil.SortPatch(origFiles)
1856
+
1857
+
// text content of patch may be identical, but a jj rebase might have forwarded it
1858
+
//
1859
+
// we still need to update the hash in submission.Patch and submission.SourceRev
1860
+
if patchutil.Equal(newFiles, origFiles) &&
1861
+
origHeader.Title == newHeader.Title &&
1862
+
origHeader.Body == newHeader.Body {
1863
+
unchanged[op.ChangeId] = struct{}{}
1864
+
} else {
1865
+
updated[op.ChangeId] = struct{}{}
1866
+
}
1982
1867
}
1983
1868
}
1984
1869
···
2045
1930
continue
2046
1931
}
2047
1932
2048
-
// resubmit the new pull
2049
-
pullAt := op.AtUri()
2050
-
newRoundNumber := len(op.Submissions)
2051
-
newPatch := np.LatestPatch()
2052
-
combinedPatch := np.LatestSubmission().Combined
2053
-
newSourceRev := np.LatestSha()
2054
-
err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
1933
+
submission := np.Submissions[np.LastRoundNumber()]
1934
+
1935
+
// resubmit the old pull
1936
+
err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev)
1937
+
1938
+
if err != nil {
1939
+
log.Println("failed to update pull", err, op.PullId)
1940
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1941
+
return
1942
+
}
1943
+
1944
+
record := op.AsRecord()
1945
+
record.Patch = submission.Patch
1946
+
1947
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1948
+
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
1949
+
Collection: tangled.RepoPullNSID,
1950
+
Rkey: op.Rkey,
1951
+
Value: &lexutil.LexiconTypeDecoder{
1952
+
Val: &record,
1953
+
},
1954
+
},
1955
+
})
1956
+
}
1957
+
1958
+
// unchanged pulls are edited without starting a new round
1959
+
//
1960
+
// update source-revs & patches without advancing rounds
1961
+
for changeId := range unchanged {
1962
+
op, _ := origById[changeId]
1963
+
np, _ := newById[changeId]
1964
+
1965
+
origSubmission := op.Submissions[op.LastRoundNumber()]
1966
+
newSubmission := np.Submissions[np.LastRoundNumber()]
1967
+
1968
+
log.Println("moving unchanged change id : ", changeId)
1969
+
1970
+
err := db.UpdatePull(
1971
+
tx,
1972
+
newSubmission.Patch,
1973
+
newSubmission.SourceRev,
1974
+
db.FilterEq("id", origSubmission.ID),
1975
+
)
1976
+
2055
1977
if err != nil {
2056
1978
log.Println("failed to update pull", err, op.PullId)
2057
1979
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2058
1980
return
2059
1981
}
2060
1982
2061
-
record := np.AsRecord()
1983
+
record := op.AsRecord()
1984
+
record.Patch = newSubmission.Patch
2062
1985
2063
1986
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2064
1987
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
···
2103
2026
return
2104
2027
}
2105
2028
2106
-
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
2029
+
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
2107
2030
Repo: user.Did,
2108
2031
Writes: writes,
2109
2032
})
···
2117
2040
}
2118
2041
2119
2042
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
2120
-
user := s.oauth.GetUser(r)
2121
2043
f, err := s.repoResolver.Resolve(r)
2122
2044
if err != nil {
2123
2045
log.Println("failed to resolve repo:", err)
···
2215
2137
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2216
2138
return
2217
2139
}
2218
-
p.State = models.PullMerged
2219
2140
}
2220
2141
2221
2142
err = tx.Commit()
···
2228
2149
2229
2150
// notify about the pull merge
2230
2151
for _, p := range pullsToMerge {
2231
-
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2152
+
s.notifier.NewPullMerged(r.Context(), p)
2232
2153
}
2233
2154
2234
2155
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
···
2289
2210
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2290
2211
return
2291
2212
}
2292
-
p.State = models.PullClosed
2293
2213
}
2294
2214
2295
2215
// Commit the transaction
···
2300
2220
}
2301
2221
2302
2222
for _, p := range pullsToClose {
2303
-
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2223
+
s.notifier.NewPullClosed(r.Context(), p)
2304
2224
}
2305
2225
2306
2226
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
2362
2282
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2363
2283
return
2364
2284
}
2365
-
p.State = models.PullOpen
2366
2285
}
2367
2286
2368
2287
// Commit the transaction
···
2370
2289
log.Println("failed to commit transaction", err)
2371
2290
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2372
2291
return
2373
-
}
2374
-
2375
-
for _, p := range pullsToReopen {
2376
-
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2377
2292
}
2378
2293
2379
2294
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
2407
2322
initialSubmission := models.PullSubmission{
2408
2323
Patch: fp.Raw,
2409
2324
SourceRev: fp.SHA,
2410
-
Combined: fp.Raw,
2411
2325
}
2412
2326
pull := models.Pull{
2413
2327
Title: title,
-1
appview/pulls/router.go
-1
appview/pulls/router.go
-49
appview/repo/archive.go
-49
appview/repo/archive.go
···
1
-
package repo
2
-
3
-
import (
4
-
"fmt"
5
-
"net/http"
6
-
"net/url"
7
-
"strings"
8
-
9
-
"tangled.org/core/api/tangled"
10
-
xrpcclient "tangled.org/core/appview/xrpcclient"
11
-
12
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
13
-
"github.com/go-chi/chi/v5"
14
-
"github.com/go-git/go-git/v5/plumbing"
15
-
)
16
-
17
-
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
18
-
l := rp.logger.With("handler", "DownloadArchive")
19
-
ref := chi.URLParam(r, "ref")
20
-
ref, _ = url.PathUnescape(ref)
21
-
f, err := rp.repoResolver.Resolve(r)
22
-
if err != nil {
23
-
l.Error("failed to get repo and knot", "err", err)
24
-
return
25
-
}
26
-
scheme := "http"
27
-
if !rp.config.Core.Dev {
28
-
scheme = "https"
29
-
}
30
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
31
-
xrpcc := &indigoxrpc.Client{
32
-
Host: host,
33
-
}
34
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
35
-
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
36
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
37
-
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
38
-
rp.pages.Error503(w)
39
-
return
40
-
}
41
-
// Set headers for file download, just pass along whatever the knot specifies
42
-
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
43
-
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
44
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
45
-
w.Header().Set("Content-Type", "application/gzip")
46
-
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
47
-
// Write the archive data directly
48
-
w.Write(archiveBytes)
49
-
}
+10
-11
appview/repo/artifact.go
+10
-11
appview/repo/artifact.go
···
10
10
"net/url"
11
11
"time"
12
12
13
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
+
lexutil "github.com/bluesky-social/indigo/lex/util"
15
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
16
+
"github.com/dustin/go-humanize"
17
+
"github.com/go-chi/chi/v5"
18
+
"github.com/go-git/go-git/v5/plumbing"
19
+
"github.com/ipfs/go-cid"
13
20
"tangled.org/core/api/tangled"
14
21
"tangled.org/core/appview/db"
15
22
"tangled.org/core/appview/models"
···
18
25
"tangled.org/core/appview/xrpcclient"
19
26
"tangled.org/core/tid"
20
27
"tangled.org/core/types"
21
-
22
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
23
-
lexutil "github.com/bluesky-social/indigo/lex/util"
24
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
25
-
"github.com/dustin/go-humanize"
26
-
"github.com/go-chi/chi/v5"
27
-
"github.com/go-git/go-git/v5/plumbing"
28
-
"github.com/ipfs/go-cid"
29
28
)
30
29
31
30
// TODO: proper statuses here on early exit
···
61
60
return
62
61
}
63
62
64
-
uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file)
63
+
uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file)
65
64
if err != nil {
66
65
log.Println("failed to upload blob", err)
67
66
rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.")
···
73
72
rkey := tid.TID()
74
73
createdAt := time.Now()
75
74
76
-
putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
75
+
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
77
76
Collection: tangled.RepoArtifactNSID,
78
77
Repo: user.Did,
79
78
Rkey: rkey,
···
250
249
return
251
250
}
252
251
253
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
252
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
254
253
Collection: tangled.RepoArtifactNSID,
255
254
Repo: user.Did,
256
255
Rkey: artifact.Rkey,
-291
appview/repo/blob.go
-291
appview/repo/blob.go
···
1
-
package repo
2
-
3
-
import (
4
-
"encoding/base64"
5
-
"fmt"
6
-
"io"
7
-
"net/http"
8
-
"net/url"
9
-
"path/filepath"
10
-
"slices"
11
-
"strings"
12
-
13
-
"tangled.org/core/api/tangled"
14
-
"tangled.org/core/appview/config"
15
-
"tangled.org/core/appview/models"
16
-
"tangled.org/core/appview/pages"
17
-
"tangled.org/core/appview/pages/markup"
18
-
"tangled.org/core/appview/reporesolver"
19
-
xrpcclient "tangled.org/core/appview/xrpcclient"
20
-
21
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
22
-
"github.com/go-chi/chi/v5"
23
-
)
24
-
25
-
// the content can be one of the following:
26
-
//
27
-
// - code : text | | raw
28
-
// - markup : text | rendered | raw
29
-
// - svg : text | rendered | raw
30
-
// - png : | rendered | raw
31
-
// - video : | rendered | raw
32
-
// - submodule : | rendered |
33
-
// - rest : | |
34
-
func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) {
35
-
l := rp.logger.With("handler", "RepoBlob")
36
-
37
-
f, err := rp.repoResolver.Resolve(r)
38
-
if err != nil {
39
-
l.Error("failed to get repo and knot", "err", err)
40
-
return
41
-
}
42
-
43
-
ref := chi.URLParam(r, "ref")
44
-
ref, _ = url.PathUnescape(ref)
45
-
46
-
filePath := chi.URLParam(r, "*")
47
-
filePath, _ = url.PathUnescape(filePath)
48
-
49
-
scheme := "http"
50
-
if !rp.config.Core.Dev {
51
-
scheme = "https"
52
-
}
53
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
54
-
xrpcc := &indigoxrpc.Client{
55
-
Host: host,
56
-
}
57
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
58
-
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
59
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
60
-
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
61
-
rp.pages.Error503(w)
62
-
return
63
-
}
64
-
65
-
// Use XRPC response directly instead of converting to internal types
66
-
var breadcrumbs [][]string
67
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
68
-
if filePath != "" {
69
-
for idx, elem := range strings.Split(filePath, "/") {
70
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
71
-
}
72
-
}
73
-
74
-
// Create the blob view
75
-
blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query())
76
-
77
-
user := rp.oauth.GetUser(r)
78
-
79
-
rp.pages.RepoBlob(w, pages.RepoBlobParams{
80
-
LoggedInUser: user,
81
-
RepoInfo: f.RepoInfo(user),
82
-
BreadCrumbs: breadcrumbs,
83
-
BlobView: blobView,
84
-
RepoBlob_Output: resp,
85
-
})
86
-
}
87
-
88
-
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
89
-
l := rp.logger.With("handler", "RepoBlobRaw")
90
-
91
-
f, err := rp.repoResolver.Resolve(r)
92
-
if err != nil {
93
-
l.Error("failed to get repo and knot", "err", err)
94
-
w.WriteHeader(http.StatusBadRequest)
95
-
return
96
-
}
97
-
98
-
ref := chi.URLParam(r, "ref")
99
-
ref, _ = url.PathUnescape(ref)
100
-
101
-
filePath := chi.URLParam(r, "*")
102
-
filePath, _ = url.PathUnescape(filePath)
103
-
104
-
scheme := "http"
105
-
if !rp.config.Core.Dev {
106
-
scheme = "https"
107
-
}
108
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
109
-
baseURL := &url.URL{
110
-
Scheme: scheme,
111
-
Host: f.Knot,
112
-
Path: "/xrpc/sh.tangled.repo.blob",
113
-
}
114
-
query := baseURL.Query()
115
-
query.Set("repo", repo)
116
-
query.Set("ref", ref)
117
-
query.Set("path", filePath)
118
-
query.Set("raw", "true")
119
-
baseURL.RawQuery = query.Encode()
120
-
blobURL := baseURL.String()
121
-
req, err := http.NewRequest("GET", blobURL, nil)
122
-
if err != nil {
123
-
l.Error("failed to create request", "err", err)
124
-
return
125
-
}
126
-
127
-
// forward the If-None-Match header
128
-
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
129
-
req.Header.Set("If-None-Match", clientETag)
130
-
}
131
-
client := &http.Client{}
132
-
133
-
resp, err := client.Do(req)
134
-
if err != nil {
135
-
l.Error("failed to reach knotserver", "err", err)
136
-
rp.pages.Error503(w)
137
-
return
138
-
}
139
-
140
-
defer resp.Body.Close()
141
-
142
-
// forward 304 not modified
143
-
if resp.StatusCode == http.StatusNotModified {
144
-
w.WriteHeader(http.StatusNotModified)
145
-
return
146
-
}
147
-
148
-
if resp.StatusCode != http.StatusOK {
149
-
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
150
-
w.WriteHeader(resp.StatusCode)
151
-
_, _ = io.Copy(w, resp.Body)
152
-
return
153
-
}
154
-
155
-
contentType := resp.Header.Get("Content-Type")
156
-
body, err := io.ReadAll(resp.Body)
157
-
if err != nil {
158
-
l.Error("error reading response body from knotserver", "err", err)
159
-
w.WriteHeader(http.StatusInternalServerError)
160
-
return
161
-
}
162
-
163
-
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
164
-
// serve all textual content as text/plain
165
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
166
-
w.Write(body)
167
-
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
168
-
// serve images and videos with their original content type
169
-
w.Header().Set("Content-Type", contentType)
170
-
w.Write(body)
171
-
} else {
172
-
w.WriteHeader(http.StatusUnsupportedMediaType)
173
-
w.Write([]byte("unsupported content type"))
174
-
return
175
-
}
176
-
}
177
-
178
-
// NewBlobView creates a BlobView from the XRPC response
179
-
func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView {
180
-
view := models.BlobView{
181
-
Contents: "",
182
-
Lines: 0,
183
-
}
184
-
185
-
// Set size
186
-
if resp.Size != nil {
187
-
view.SizeHint = uint64(*resp.Size)
188
-
} else if resp.Content != nil {
189
-
view.SizeHint = uint64(len(*resp.Content))
190
-
}
191
-
192
-
if resp.Submodule != nil {
193
-
view.ContentType = models.BlobContentTypeSubmodule
194
-
view.HasRenderedView = true
195
-
view.ContentSrc = resp.Submodule.Url
196
-
return view
197
-
}
198
-
199
-
// Determine if binary
200
-
if resp.IsBinary != nil && *resp.IsBinary {
201
-
view.ContentSrc = generateBlobURL(config, f, ref, filePath)
202
-
ext := strings.ToLower(filepath.Ext(resp.Path))
203
-
204
-
switch ext {
205
-
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
206
-
view.ContentType = models.BlobContentTypeImage
207
-
view.HasRawView = true
208
-
view.HasRenderedView = true
209
-
view.ShowingRendered = true
210
-
211
-
case ".svg":
212
-
view.ContentType = models.BlobContentTypeSvg
213
-
view.HasRawView = true
214
-
view.HasTextView = true
215
-
view.HasRenderedView = true
216
-
view.ShowingRendered = queryParams.Get("code") != "true"
217
-
if resp.Content != nil {
218
-
bytes, _ := base64.StdEncoding.DecodeString(*resp.Content)
219
-
view.Contents = string(bytes)
220
-
view.Lines = strings.Count(view.Contents, "\n") + 1
221
-
}
222
-
223
-
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
224
-
view.ContentType = models.BlobContentTypeVideo
225
-
view.HasRawView = true
226
-
view.HasRenderedView = true
227
-
view.ShowingRendered = true
228
-
}
229
-
230
-
return view
231
-
}
232
-
233
-
// otherwise, we are dealing with text content
234
-
view.HasRawView = true
235
-
view.HasTextView = true
236
-
237
-
if resp.Content != nil {
238
-
view.Contents = *resp.Content
239
-
view.Lines = strings.Count(view.Contents, "\n") + 1
240
-
}
241
-
242
-
// with text, we may be dealing with markdown
243
-
format := markup.GetFormat(resp.Path)
244
-
if format == markup.FormatMarkdown {
245
-
view.ContentType = models.BlobContentTypeMarkup
246
-
view.HasRenderedView = true
247
-
view.ShowingRendered = queryParams.Get("code") != "true"
248
-
}
249
-
250
-
return view
251
-
}
252
-
253
-
func generateBlobURL(config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string) string {
254
-
scheme := "http"
255
-
if !config.Core.Dev {
256
-
scheme = "https"
257
-
}
258
-
259
-
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
260
-
baseURL := &url.URL{
261
-
Scheme: scheme,
262
-
Host: f.Knot,
263
-
Path: "/xrpc/sh.tangled.repo.blob",
264
-
}
265
-
query := baseURL.Query()
266
-
query.Set("repo", repoName)
267
-
query.Set("ref", ref)
268
-
query.Set("path", filePath)
269
-
query.Set("raw", "true")
270
-
baseURL.RawQuery = query.Encode()
271
-
blobURL := baseURL.String()
272
-
273
-
if !config.Core.Dev {
274
-
return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL)
275
-
}
276
-
return blobURL
277
-
}
278
-
279
-
func isTextualMimeType(mimeType string) bool {
280
-
textualTypes := []string{
281
-
"application/json",
282
-
"application/xml",
283
-
"application/yaml",
284
-
"application/x-yaml",
285
-
"application/toml",
286
-
"application/javascript",
287
-
"application/ecmascript",
288
-
"message/",
289
-
}
290
-
return slices.Contains(textualTypes, mimeType)
291
-
}
-95
appview/repo/branches.go
-95
appview/repo/branches.go
···
1
-
package repo
2
-
3
-
import (
4
-
"encoding/json"
5
-
"fmt"
6
-
"net/http"
7
-
8
-
"tangled.org/core/api/tangled"
9
-
"tangled.org/core/appview/oauth"
10
-
"tangled.org/core/appview/pages"
11
-
xrpcclient "tangled.org/core/appview/xrpcclient"
12
-
"tangled.org/core/types"
13
-
14
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
15
-
)
16
-
17
-
func (rp *Repo) Branches(w http.ResponseWriter, r *http.Request) {
18
-
l := rp.logger.With("handler", "RepoBranches")
19
-
f, err := rp.repoResolver.Resolve(r)
20
-
if err != nil {
21
-
l.Error("failed to get repo and knot", "err", err)
22
-
return
23
-
}
24
-
scheme := "http"
25
-
if !rp.config.Core.Dev {
26
-
scheme = "https"
27
-
}
28
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
29
-
xrpcc := &indigoxrpc.Client{
30
-
Host: host,
31
-
}
32
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
33
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
34
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
35
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
36
-
rp.pages.Error503(w)
37
-
return
38
-
}
39
-
var result types.RepoBranchesResponse
40
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
41
-
l.Error("failed to decode XRPC response", "err", err)
42
-
rp.pages.Error503(w)
43
-
return
44
-
}
45
-
sortBranches(result.Branches)
46
-
user := rp.oauth.GetUser(r)
47
-
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
48
-
LoggedInUser: user,
49
-
RepoInfo: f.RepoInfo(user),
50
-
RepoBranchesResponse: result,
51
-
})
52
-
}
53
-
54
-
func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) {
55
-
l := rp.logger.With("handler", "DeleteBranch")
56
-
f, err := rp.repoResolver.Resolve(r)
57
-
if err != nil {
58
-
l.Error("failed to get repo and knot", "err", err)
59
-
return
60
-
}
61
-
noticeId := "delete-branch-error"
62
-
fail := func(msg string, err error) {
63
-
l.Error(msg, "err", err)
64
-
rp.pages.Notice(w, noticeId, msg)
65
-
}
66
-
branch := r.FormValue("branch")
67
-
if branch == "" {
68
-
fail("No branch provided.", nil)
69
-
return
70
-
}
71
-
client, err := rp.oauth.ServiceClient(
72
-
r,
73
-
oauth.WithService(f.Knot),
74
-
oauth.WithLxm(tangled.RepoDeleteBranchNSID),
75
-
oauth.WithDev(rp.config.Core.Dev),
76
-
)
77
-
if err != nil {
78
-
fail("Failed to connect to knotserver", nil)
79
-
return
80
-
}
81
-
err = tangled.RepoDeleteBranch(
82
-
r.Context(),
83
-
client,
84
-
&tangled.RepoDeleteBranch_Input{
85
-
Branch: branch,
86
-
Repo: f.RepoAt().String(),
87
-
},
88
-
)
89
-
if err := xrpcclient.HandleXrpcErr(err); err != nil {
90
-
fail(fmt.Sprintf("Failed to delete branch: %s", err), err)
91
-
return
92
-
}
93
-
l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt())
94
-
rp.pages.HxRefresh(w)
95
-
}
-214
appview/repo/compare.go
-214
appview/repo/compare.go
···
1
-
package repo
2
-
3
-
import (
4
-
"encoding/json"
5
-
"fmt"
6
-
"net/http"
7
-
"net/url"
8
-
"strings"
9
-
10
-
"tangled.org/core/api/tangled"
11
-
"tangled.org/core/appview/pages"
12
-
xrpcclient "tangled.org/core/appview/xrpcclient"
13
-
"tangled.org/core/patchutil"
14
-
"tangled.org/core/types"
15
-
16
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
17
-
"github.com/go-chi/chi/v5"
18
-
)
19
-
20
-
func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) {
21
-
l := rp.logger.With("handler", "RepoCompareNew")
22
-
23
-
user := rp.oauth.GetUser(r)
24
-
f, err := rp.repoResolver.Resolve(r)
25
-
if err != nil {
26
-
l.Error("failed to get repo and knot", "err", err)
27
-
return
28
-
}
29
-
30
-
scheme := "http"
31
-
if !rp.config.Core.Dev {
32
-
scheme = "https"
33
-
}
34
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
35
-
xrpcc := &indigoxrpc.Client{
36
-
Host: host,
37
-
}
38
-
39
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
40
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
41
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
42
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
43
-
rp.pages.Error503(w)
44
-
return
45
-
}
46
-
47
-
var branchResult types.RepoBranchesResponse
48
-
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
49
-
l.Error("failed to decode XRPC branches response", "err", err)
50
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
51
-
return
52
-
}
53
-
branches := branchResult.Branches
54
-
55
-
sortBranches(branches)
56
-
57
-
var defaultBranch string
58
-
for _, b := range branches {
59
-
if b.IsDefault {
60
-
defaultBranch = b.Name
61
-
}
62
-
}
63
-
64
-
base := defaultBranch
65
-
head := defaultBranch
66
-
67
-
params := r.URL.Query()
68
-
queryBase := params.Get("base")
69
-
queryHead := params.Get("head")
70
-
if queryBase != "" {
71
-
base = queryBase
72
-
}
73
-
if queryHead != "" {
74
-
head = queryHead
75
-
}
76
-
77
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
78
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
79
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
80
-
rp.pages.Error503(w)
81
-
return
82
-
}
83
-
84
-
var tags types.RepoTagsResponse
85
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
86
-
l.Error("failed to decode XRPC tags response", "err", err)
87
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
88
-
return
89
-
}
90
-
91
-
repoinfo := f.RepoInfo(user)
92
-
93
-
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
94
-
LoggedInUser: user,
95
-
RepoInfo: repoinfo,
96
-
Branches: branches,
97
-
Tags: tags.Tags,
98
-
Base: base,
99
-
Head: head,
100
-
})
101
-
}
102
-
103
-
func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) {
104
-
l := rp.logger.With("handler", "RepoCompare")
105
-
106
-
user := rp.oauth.GetUser(r)
107
-
f, err := rp.repoResolver.Resolve(r)
108
-
if err != nil {
109
-
l.Error("failed to get repo and knot", "err", err)
110
-
return
111
-
}
112
-
113
-
var diffOpts types.DiffOpts
114
-
if d := r.URL.Query().Get("diff"); d == "split" {
115
-
diffOpts.Split = true
116
-
}
117
-
118
-
// if user is navigating to one of
119
-
// /compare/{base}/{head}
120
-
// /compare/{base}...{head}
121
-
base := chi.URLParam(r, "base")
122
-
head := chi.URLParam(r, "head")
123
-
if base == "" && head == "" {
124
-
rest := chi.URLParam(r, "*") // master...feature/xyz
125
-
parts := strings.SplitN(rest, "...", 2)
126
-
if len(parts) == 2 {
127
-
base = parts[0]
128
-
head = parts[1]
129
-
}
130
-
}
131
-
132
-
base, _ = url.PathUnescape(base)
133
-
head, _ = url.PathUnescape(head)
134
-
135
-
if base == "" || head == "" {
136
-
l.Error("invalid comparison")
137
-
rp.pages.Error404(w)
138
-
return
139
-
}
140
-
141
-
scheme := "http"
142
-
if !rp.config.Core.Dev {
143
-
scheme = "https"
144
-
}
145
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
146
-
xrpcc := &indigoxrpc.Client{
147
-
Host: host,
148
-
}
149
-
150
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
151
-
152
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
153
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
154
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
155
-
rp.pages.Error503(w)
156
-
return
157
-
}
158
-
159
-
var branches types.RepoBranchesResponse
160
-
if err := json.Unmarshal(branchBytes, &branches); err != nil {
161
-
l.Error("failed to decode XRPC branches response", "err", err)
162
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
163
-
return
164
-
}
165
-
166
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
167
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
168
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
169
-
rp.pages.Error503(w)
170
-
return
171
-
}
172
-
173
-
var tags types.RepoTagsResponse
174
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
175
-
l.Error("failed to decode XRPC tags response", "err", err)
176
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
177
-
return
178
-
}
179
-
180
-
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
181
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
182
-
l.Error("failed to call XRPC repo.compare", "err", xrpcerr)
183
-
rp.pages.Error503(w)
184
-
return
185
-
}
186
-
187
-
var formatPatch types.RepoFormatPatchResponse
188
-
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
189
-
l.Error("failed to decode XRPC compare response", "err", err)
190
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
191
-
return
192
-
}
193
-
194
-
var diff types.NiceDiff
195
-
if formatPatch.CombinedPatchRaw != "" {
196
-
diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base)
197
-
} else {
198
-
diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
199
-
}
200
-
201
-
repoinfo := f.RepoInfo(user)
202
-
203
-
rp.pages.RepoCompare(w, pages.RepoCompareParams{
204
-
LoggedInUser: user,
205
-
RepoInfo: repoinfo,
206
-
Branches: branches.Branches,
207
-
Tags: tags.Tags,
208
-
Base: base,
209
-
Head: head,
210
-
Diff: &diff,
211
-
DiffOpts: diffOpts,
212
-
})
213
-
214
-
}
+1
-1
appview/repo/feed.go
+1
-1
appview/repo/feed.go
···
146
146
return fmt.Sprintf("%s in %s", base, repoName)
147
147
}
148
148
149
-
func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) {
149
+
func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) {
150
150
f, err := rp.repoResolver.Resolve(r)
151
151
if err != nil {
152
152
log.Println("failed to fully resolve repo:", err)
+23
-36
appview/repo/index.go
+23
-36
appview/repo/index.go
···
3
3
import (
4
4
"errors"
5
5
"fmt"
6
-
"log/slog"
6
+
"log"
7
7
"net/http"
8
8
"net/url"
9
9
"slices"
···
30
30
"github.com/go-enry/go-enry/v2"
31
31
)
32
32
33
-
func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) {
34
-
l := rp.logger.With("handler", "RepoIndex")
35
-
33
+
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
36
34
ref := chi.URLParam(r, "ref")
37
35
ref, _ = url.PathUnescape(ref)
38
36
39
37
f, err := rp.repoResolver.Resolve(r)
40
38
if err != nil {
41
-
l.Error("failed to fully resolve repo", "err", err)
39
+
log.Println("failed to fully resolve repo", err)
42
40
return
43
41
}
44
42
···
58
56
result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
59
57
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
60
58
if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) {
61
-
l.Error("failed to call XRPC repo.index", "err", err)
59
+
log.Println("failed to call XRPC repo.index", err)
62
60
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
63
61
LoggedInUser: user,
64
62
NeedsKnotUpgrade: true,
···
68
66
}
69
67
70
68
rp.pages.Error503(w)
71
-
l.Error("failed to build index response", "err", err)
69
+
log.Println("failed to build index response", err)
72
70
return
73
71
}
74
72
···
121
119
emails := uniqueEmails(commitsTrunc)
122
120
emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
123
121
if err != nil {
124
-
l.Error("failed to get email to did map", "err", err)
122
+
log.Println("failed to get email to did map", err)
125
123
}
126
124
127
125
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc)
128
126
if err != nil {
129
-
l.Error("failed to GetVerifiedObjectCommits", "err", err)
127
+
log.Println(err)
130
128
}
131
129
132
130
// TODO: a bit dirty
133
-
languageInfo, err := rp.getLanguageInfo(r.Context(), l, f, xrpcc, result.Ref, ref == "")
131
+
languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "")
134
132
if err != nil {
135
-
l.Warn("failed to compute language percentages", "err", err)
133
+
log.Printf("failed to compute language percentages: %s", err)
136
134
// non-fatal
137
135
}
138
136
···
142
140
}
143
141
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
144
142
if err != nil {
145
-
l.Error("failed to fetch pipeline statuses", "err", err)
143
+
log.Printf("failed to fetch pipeline statuses: %s", err)
146
144
// non-fatal
147
145
}
148
146
···
154
152
CommitsTrunc: commitsTrunc,
155
153
TagsTrunc: tagsTrunc,
156
154
// ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands
157
-
BranchesTrunc: branchesTrunc,
158
-
EmailToDid: emailToDidMap,
159
-
VerifiedCommits: vc,
160
-
Languages: languageInfo,
161
-
Pipelines: pipelines,
155
+
BranchesTrunc: branchesTrunc,
156
+
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
157
+
VerifiedCommits: vc,
158
+
Languages: languageInfo,
159
+
Pipelines: pipelines,
162
160
})
163
161
}
164
162
165
163
func (rp *Repo) getLanguageInfo(
166
164
ctx context.Context,
167
-
l *slog.Logger,
168
165
f *reporesolver.ResolvedRepo,
169
166
xrpcc *indigoxrpc.Client,
170
167
currentRef string,
···
183
180
ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo)
184
181
if err != nil {
185
182
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
186
-
l.Error("failed to call XRPC repo.languages", "err", xrpcerr)
183
+
log.Println("failed to call XRPC repo.languages", xrpcerr)
187
184
return nil, xrpcerr
188
185
}
189
186
return nil, err
···
203
200
})
204
201
}
205
202
206
-
tx, err := rp.db.Begin()
207
-
if err != nil {
208
-
return nil, err
209
-
}
210
-
defer tx.Rollback()
211
-
212
203
// update appview's cache
213
-
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
204
+
err = db.InsertRepoLanguages(rp.db, langs)
214
205
if err != nil {
215
206
// non-fatal
216
-
l.Error("failed to cache lang results", "err", err)
217
-
}
218
-
219
-
err = tx.Commit()
220
-
if err != nil {
221
-
return nil, err
207
+
log.Println("failed to cache lang results", err)
222
208
}
223
209
}
224
210
···
351
337
if treeResp != nil && treeResp.Files != nil {
352
338
for _, file := range treeResp.Files {
353
339
niceFile := types.NiceTree{
354
-
Name: file.Name,
355
-
Mode: file.Mode,
356
-
Size: file.Size,
340
+
IsFile: file.Is_file,
341
+
IsSubtree: file.Is_subtree,
342
+
Name: file.Name,
343
+
Mode: file.Mode,
344
+
Size: file.Size,
357
345
}
358
-
359
346
if file.Last_commit != nil {
360
347
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
361
348
niceFile.LastCommit = &types.LastCommitInfo{
-223
appview/repo/log.go
-223
appview/repo/log.go
···
1
-
package repo
2
-
3
-
import (
4
-
"encoding/json"
5
-
"fmt"
6
-
"net/http"
7
-
"net/url"
8
-
"strconv"
9
-
10
-
"tangled.org/core/api/tangled"
11
-
"tangled.org/core/appview/commitverify"
12
-
"tangled.org/core/appview/db"
13
-
"tangled.org/core/appview/models"
14
-
"tangled.org/core/appview/pages"
15
-
xrpcclient "tangled.org/core/appview/xrpcclient"
16
-
"tangled.org/core/types"
17
-
18
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
19
-
"github.com/go-chi/chi/v5"
20
-
"github.com/go-git/go-git/v5/plumbing"
21
-
)
22
-
23
-
func (rp *Repo) Log(w http.ResponseWriter, r *http.Request) {
24
-
l := rp.logger.With("handler", "RepoLog")
25
-
26
-
f, err := rp.repoResolver.Resolve(r)
27
-
if err != nil {
28
-
l.Error("failed to fully resolve repo", "err", err)
29
-
return
30
-
}
31
-
32
-
page := 1
33
-
if r.URL.Query().Get("page") != "" {
34
-
page, err = strconv.Atoi(r.URL.Query().Get("page"))
35
-
if err != nil {
36
-
page = 1
37
-
}
38
-
}
39
-
40
-
ref := chi.URLParam(r, "ref")
41
-
ref, _ = url.PathUnescape(ref)
42
-
43
-
scheme := "http"
44
-
if !rp.config.Core.Dev {
45
-
scheme = "https"
46
-
}
47
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
48
-
xrpcc := &indigoxrpc.Client{
49
-
Host: host,
50
-
}
51
-
52
-
limit := int64(60)
53
-
cursor := ""
54
-
if page > 1 {
55
-
// Convert page number to cursor (offset)
56
-
offset := (page - 1) * int(limit)
57
-
cursor = strconv.Itoa(offset)
58
-
}
59
-
60
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
61
-
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
62
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
63
-
l.Error("failed to call XRPC repo.log", "err", xrpcerr)
64
-
rp.pages.Error503(w)
65
-
return
66
-
}
67
-
68
-
var xrpcResp types.RepoLogResponse
69
-
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
70
-
l.Error("failed to decode XRPC response", "err", err)
71
-
rp.pages.Error503(w)
72
-
return
73
-
}
74
-
75
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
76
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
77
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
78
-
rp.pages.Error503(w)
79
-
return
80
-
}
81
-
82
-
tagMap := make(map[string][]string)
83
-
if tagBytes != nil {
84
-
var tagResp types.RepoTagsResponse
85
-
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
86
-
for _, tag := range tagResp.Tags {
87
-
hash := tag.Hash
88
-
if tag.Tag != nil {
89
-
hash = tag.Tag.Target.String()
90
-
}
91
-
tagMap[hash] = append(tagMap[hash], tag.Name)
92
-
}
93
-
}
94
-
}
95
-
96
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
97
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
98
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
99
-
rp.pages.Error503(w)
100
-
return
101
-
}
102
-
103
-
if branchBytes != nil {
104
-
var branchResp types.RepoBranchesResponse
105
-
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
106
-
for _, branch := range branchResp.Branches {
107
-
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
108
-
}
109
-
}
110
-
}
111
-
112
-
user := rp.oauth.GetUser(r)
113
-
114
-
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
115
-
if err != nil {
116
-
l.Error("failed to fetch email to did mapping", "err", err)
117
-
}
118
-
119
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
120
-
if err != nil {
121
-
l.Error("failed to GetVerifiedObjectCommits", "err", err)
122
-
}
123
-
124
-
repoInfo := f.RepoInfo(user)
125
-
126
-
var shas []string
127
-
for _, c := range xrpcResp.Commits {
128
-
shas = append(shas, c.Hash.String())
129
-
}
130
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
131
-
if err != nil {
132
-
l.Error("failed to getPipelineStatuses", "err", err)
133
-
// non-fatal
134
-
}
135
-
136
-
rp.pages.RepoLog(w, pages.RepoLogParams{
137
-
LoggedInUser: user,
138
-
TagMap: tagMap,
139
-
RepoInfo: repoInfo,
140
-
RepoLogResponse: xrpcResp,
141
-
EmailToDid: emailToDidMap,
142
-
VerifiedCommits: vc,
143
-
Pipelines: pipelines,
144
-
})
145
-
}
146
-
147
-
func (rp *Repo) Commit(w http.ResponseWriter, r *http.Request) {
148
-
l := rp.logger.With("handler", "RepoCommit")
149
-
150
-
f, err := rp.repoResolver.Resolve(r)
151
-
if err != nil {
152
-
l.Error("failed to fully resolve repo", "err", err)
153
-
return
154
-
}
155
-
ref := chi.URLParam(r, "ref")
156
-
ref, _ = url.PathUnescape(ref)
157
-
158
-
var diffOpts types.DiffOpts
159
-
if d := r.URL.Query().Get("diff"); d == "split" {
160
-
diffOpts.Split = true
161
-
}
162
-
163
-
if !plumbing.IsHash(ref) {
164
-
rp.pages.Error404(w)
165
-
return
166
-
}
167
-
168
-
scheme := "http"
169
-
if !rp.config.Core.Dev {
170
-
scheme = "https"
171
-
}
172
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
173
-
xrpcc := &indigoxrpc.Client{
174
-
Host: host,
175
-
}
176
-
177
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
178
-
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
179
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
180
-
l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
181
-
rp.pages.Error503(w)
182
-
return
183
-
}
184
-
185
-
var result types.RepoCommitResponse
186
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
187
-
l.Error("failed to decode XRPC response", "err", err)
188
-
rp.pages.Error503(w)
189
-
return
190
-
}
191
-
192
-
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
193
-
if err != nil {
194
-
l.Error("failed to get email to did mapping", "err", err)
195
-
}
196
-
197
-
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
198
-
if err != nil {
199
-
l.Error("failed to GetVerifiedCommits", "err", err)
200
-
}
201
-
202
-
user := rp.oauth.GetUser(r)
203
-
repoInfo := f.RepoInfo(user)
204
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
205
-
if err != nil {
206
-
l.Error("failed to getPipelineStatuses", "err", err)
207
-
// non-fatal
208
-
}
209
-
var pipeline *models.Pipeline
210
-
if p, ok := pipelines[result.Diff.Commit.This]; ok {
211
-
pipeline = &p
212
-
}
213
-
214
-
rp.pages.RepoCommit(w, pages.RepoCommitParams{
215
-
LoggedInUser: user,
216
-
RepoInfo: f.RepoInfo(user),
217
-
RepoCommitResponse: result,
218
-
EmailToDid: emailToDidMap,
219
-
VerifiedCommit: vc,
220
-
Pipeline: pipeline,
221
-
DiffOpts: diffOpts,
222
-
})
223
-
}
-402
appview/repo/opengraph.go
-402
appview/repo/opengraph.go
···
1
-
package repo
2
-
3
-
import (
4
-
"bytes"
5
-
"context"
6
-
"encoding/hex"
7
-
"fmt"
8
-
"image/color"
9
-
"image/png"
10
-
"log"
11
-
"net/http"
12
-
"sort"
13
-
"strings"
14
-
15
-
"github.com/go-enry/go-enry/v2"
16
-
"tangled.org/core/appview/db"
17
-
"tangled.org/core/appview/models"
18
-
"tangled.org/core/appview/ogcard"
19
-
"tangled.org/core/types"
20
-
)
21
-
22
-
func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.RepoLanguageDetails) (*ogcard.Card, error) {
23
-
width, height := ogcard.DefaultSize()
24
-
mainCard, err := ogcard.NewCard(width, height)
25
-
if err != nil {
26
-
return nil, err
27
-
}
28
-
29
-
// Split: content area (75%) and language bar + icons (25%)
30
-
contentCard, bottomArea := mainCard.Split(false, 75)
31
-
32
-
// Add padding to content
33
-
contentCard.SetMargin(50)
34
-
35
-
// Split content horizontally: main content (80%) and avatar area (20%)
36
-
mainContent, avatarArea := contentCard.Split(true, 80)
37
-
38
-
// Use main content area for both repo name and description to allow dynamic wrapping.
39
-
mainContent.SetMargin(10)
40
-
41
-
var ownerHandle string
42
-
owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did)
43
-
if err != nil {
44
-
ownerHandle = repo.Did
45
-
} else {
46
-
ownerHandle = "@" + owner.Handle.String()
47
-
}
48
-
49
-
bounds := mainContent.Img.Bounds()
50
-
startX := bounds.Min.X + mainContent.Margin
51
-
startY := bounds.Min.Y + mainContent.Margin
52
-
currentX := startX
53
-
currentY := startY
54
-
lineHeight := 64 // Font size 54 + padding
55
-
textColor := color.RGBA{88, 96, 105, 255}
56
-
57
-
// Draw owner handle
58
-
ownerWidth, err := mainContent.DrawTextAtWithWidth(ownerHandle, currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left)
59
-
if err != nil {
60
-
return nil, err
61
-
}
62
-
currentX += ownerWidth
63
-
64
-
// Draw separator
65
-
sepWidth, err := mainContent.DrawTextAtWithWidth(" / ", currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left)
66
-
if err != nil {
67
-
return nil, err
68
-
}
69
-
currentX += sepWidth
70
-
71
-
words := strings.Fields(repo.Name)
72
-
spaceWidth, _ := mainContent.DrawTextAtWithWidth(" ", -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left)
73
-
if spaceWidth == 0 {
74
-
spaceWidth = 15
75
-
}
76
-
77
-
for _, word := range words {
78
-
// estimate bold width by measuring regular width and adding a multiplier
79
-
regularWidth, _ := mainContent.DrawTextAtWithWidth(word, -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left)
80
-
estimatedBoldWidth := int(float64(regularWidth) * 1.15) // Heuristic for bold text
81
-
82
-
if currentX+estimatedBoldWidth > (bounds.Max.X - mainContent.Margin) {
83
-
currentX = startX
84
-
currentY += lineHeight
85
-
}
86
-
87
-
_, err := mainContent.DrawBoldText(word, currentX, currentY, color.Black, 54, ogcard.Top, ogcard.Left)
88
-
if err != nil {
89
-
return nil, err
90
-
}
91
-
currentX += estimatedBoldWidth + spaceWidth
92
-
}
93
-
94
-
// update Y position for the description
95
-
currentY += lineHeight
96
-
97
-
// draw description
98
-
if currentY < bounds.Max.Y-mainContent.Margin {
99
-
totalHeight := float64(bounds.Dy())
100
-
repoNameHeight := float64(currentY - bounds.Min.Y)
101
-
102
-
if totalHeight > 0 && repoNameHeight < totalHeight {
103
-
repoNamePercent := (repoNameHeight / totalHeight) * 100
104
-
if repoNamePercent < 95 { // Ensure there's space left for description
105
-
_, descriptionCard := mainContent.Split(false, int(repoNamePercent))
106
-
descriptionCard.SetMargin(8)
107
-
108
-
description := repo.Description
109
-
if len(description) > 70 {
110
-
description = description[:70] + "…"
111
-
}
112
-
113
-
_, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left)
114
-
if err != nil {
115
-
log.Printf("failed to draw description: %v", err)
116
-
}
117
-
}
118
-
}
119
-
}
120
-
121
-
// Draw avatar circle on the right side
122
-
avatarBounds := avatarArea.Img.Bounds()
123
-
avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
124
-
if avatarSize > 220 {
125
-
avatarSize = 220
126
-
}
127
-
avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
128
-
avatarY := avatarBounds.Min.Y + 20
129
-
130
-
// Get avatar URL and draw it
131
-
avatarURL := rp.pages.AvatarUrl(ownerHandle, "256")
132
-
err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
133
-
if err != nil {
134
-
log.Printf("failed to draw avatar (non-fatal): %v", err)
135
-
}
136
-
137
-
// Split bottom area: icons area (65%) and language bar (35%)
138
-
iconsArea, languageBarCard := bottomArea.Split(false, 75)
139
-
140
-
// Split icons area: left side for stats (80%), right side for dolly (20%)
141
-
statsArea, dollyArea := iconsArea.Split(true, 80)
142
-
143
-
// Draw stats with icons in the stats area
144
-
starsText := repo.RepoStats.StarCount
145
-
issuesText := repo.RepoStats.IssueCount.Open
146
-
pullRequestsText := repo.RepoStats.PullCount.Open
147
-
148
-
iconColor := color.RGBA{88, 96, 105, 255}
149
-
iconSize := 36
150
-
textSize := 36.0
151
-
152
-
// Position stats in the middle of the stats area
153
-
statsBounds := statsArea.Img.Bounds()
154
-
statsX := statsBounds.Min.X + 60 // left padding
155
-
statsY := statsBounds.Min.Y
156
-
currentX = statsX
157
-
labelSize := 22.0
158
-
// Draw star icon, count, and label
159
-
// Align icon baseline with text baseline
160
-
iconBaselineOffset := int(textSize) / 2
161
-
err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
162
-
if err != nil {
163
-
log.Printf("failed to draw star icon: %v", err)
164
-
}
165
-
starIconX := currentX
166
-
currentX += iconSize + 15
167
-
168
-
starText := fmt.Sprintf("%d", starsText)
169
-
err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
170
-
if err != nil {
171
-
log.Printf("failed to draw star text: %v", err)
172
-
}
173
-
starTextWidth := len(starText) * 20
174
-
starGroupWidth := iconSize + 15 + starTextWidth
175
-
176
-
// Draw "stars" label below and centered under the icon+text group
177
-
labelY := statsY + iconSize + 15
178
-
labelX := starIconX + starGroupWidth/2
179
-
err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
180
-
if err != nil {
181
-
log.Printf("failed to draw stars label: %v", err)
182
-
}
183
-
184
-
currentX += starTextWidth + 50
185
-
186
-
// Draw issues icon, count, and label
187
-
issueStartX := currentX
188
-
err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
189
-
if err != nil {
190
-
log.Printf("failed to draw circle-dot icon: %v", err)
191
-
}
192
-
currentX += iconSize + 15
193
-
194
-
issueText := fmt.Sprintf("%d", issuesText)
195
-
err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
196
-
if err != nil {
197
-
log.Printf("failed to draw issue text: %v", err)
198
-
}
199
-
issueTextWidth := len(issueText) * 20
200
-
issueGroupWidth := iconSize + 15 + issueTextWidth
201
-
202
-
// Draw "issues" label below and centered under the icon+text group
203
-
labelX = issueStartX + issueGroupWidth/2
204
-
err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
205
-
if err != nil {
206
-
log.Printf("failed to draw issues label: %v", err)
207
-
}
208
-
209
-
currentX += issueTextWidth + 50
210
-
211
-
// Draw pull request icon, count, and label
212
-
prStartX := currentX
213
-
err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
214
-
if err != nil {
215
-
log.Printf("failed to draw git-pull-request icon: %v", err)
216
-
}
217
-
currentX += iconSize + 15
218
-
219
-
prText := fmt.Sprintf("%d", pullRequestsText)
220
-
err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
221
-
if err != nil {
222
-
log.Printf("failed to draw PR text: %v", err)
223
-
}
224
-
prTextWidth := len(prText) * 20
225
-
prGroupWidth := iconSize + 15 + prTextWidth
226
-
227
-
// Draw "pulls" label below and centered under the icon+text group
228
-
labelX = prStartX + prGroupWidth/2
229
-
err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
230
-
if err != nil {
231
-
log.Printf("failed to draw pulls label: %v", err)
232
-
}
233
-
234
-
dollyBounds := dollyArea.Img.Bounds()
235
-
dollySize := 90
236
-
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
237
-
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
238
-
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
239
-
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
240
-
if err != nil {
241
-
log.Printf("dolly silhouette not available (this is ok): %v", err)
242
-
}
243
-
244
-
// Draw language bar at bottom
245
-
err = drawLanguagesCard(languageBarCard, languageStats)
246
-
if err != nil {
247
-
log.Printf("failed to draw language bar: %v", err)
248
-
return nil, err
249
-
}
250
-
251
-
return mainCard, nil
252
-
}
253
-
254
-
// hexToColor converts a hex color to a go color
255
-
func hexToColor(colorStr string) (*color.RGBA, error) {
256
-
colorStr = strings.TrimLeft(colorStr, "#")
257
-
258
-
b, err := hex.DecodeString(colorStr)
259
-
if err != nil {
260
-
return nil, err
261
-
}
262
-
263
-
if len(b) < 3 {
264
-
return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b))
265
-
}
266
-
267
-
clr := color.RGBA{b[0], b[1], b[2], 255}
268
-
269
-
return &clr, nil
270
-
}
271
-
272
-
func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error {
273
-
bounds := card.Img.Bounds()
274
-
cardWidth := bounds.Dx()
275
-
276
-
if len(languageStats) == 0 {
277
-
// Draw a light gray bar if no languages detected
278
-
card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255})
279
-
return nil
280
-
}
281
-
282
-
// Limit to top 5 languages for the visual bar
283
-
displayLanguages := languageStats
284
-
if len(displayLanguages) > 5 {
285
-
displayLanguages = displayLanguages[:5]
286
-
}
287
-
288
-
currentX := bounds.Min.X
289
-
290
-
for _, lang := range displayLanguages {
291
-
var langColor *color.RGBA
292
-
var err error
293
-
294
-
if lang.Color != "" {
295
-
langColor, err = hexToColor(lang.Color)
296
-
if err != nil {
297
-
// Fallback to a default color
298
-
langColor = &color.RGBA{149, 157, 165, 255}
299
-
}
300
-
} else {
301
-
// Default color if no color specified
302
-
langColor = &color.RGBA{149, 157, 165, 255}
303
-
}
304
-
305
-
langWidth := float32(cardWidth) * (lang.Percentage / 100)
306
-
card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor)
307
-
currentX += int(langWidth)
308
-
}
309
-
310
-
// Fill remaining space with the last color (if any gap due to rounding)
311
-
if currentX < bounds.Max.X && len(displayLanguages) > 0 {
312
-
lastLang := displayLanguages[len(displayLanguages)-1]
313
-
var lastColor *color.RGBA
314
-
var err error
315
-
316
-
if lastLang.Color != "" {
317
-
lastColor, err = hexToColor(lastLang.Color)
318
-
if err != nil {
319
-
lastColor = &color.RGBA{149, 157, 165, 255}
320
-
}
321
-
} else {
322
-
lastColor = &color.RGBA{149, 157, 165, 255}
323
-
}
324
-
card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor)
325
-
}
326
-
327
-
return nil
328
-
}
329
-
330
-
func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) {
331
-
f, err := rp.repoResolver.Resolve(r)
332
-
if err != nil {
333
-
log.Println("failed to get repo and knot", err)
334
-
return
335
-
}
336
-
337
-
// Get language stats directly from database
338
-
var languageStats []types.RepoLanguageDetails
339
-
langs, err := db.GetRepoLanguages(
340
-
rp.db,
341
-
db.FilterEq("repo_at", f.RepoAt()),
342
-
db.FilterEq("is_default_ref", 1),
343
-
)
344
-
if err != nil {
345
-
log.Printf("failed to get language stats from db: %v", err)
346
-
// non-fatal, continue without language stats
347
-
} else if len(langs) > 0 {
348
-
var total int64
349
-
for _, l := range langs {
350
-
total += l.Bytes
351
-
}
352
-
353
-
for _, l := range langs {
354
-
percentage := float32(l.Bytes) / float32(total) * 100
355
-
color := enry.GetColor(l.Language)
356
-
languageStats = append(languageStats, types.RepoLanguageDetails{
357
-
Name: l.Language,
358
-
Percentage: percentage,
359
-
Color: color,
360
-
})
361
-
}
362
-
363
-
sort.Slice(languageStats, func(i, j int) bool {
364
-
if languageStats[i].Name == enry.OtherLanguage {
365
-
return false
366
-
}
367
-
if languageStats[j].Name == enry.OtherLanguage {
368
-
return true
369
-
}
370
-
if languageStats[i].Percentage != languageStats[j].Percentage {
371
-
return languageStats[i].Percentage > languageStats[j].Percentage
372
-
}
373
-
return languageStats[i].Name < languageStats[j].Name
374
-
})
375
-
}
376
-
377
-
card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats)
378
-
if err != nil {
379
-
log.Println("failed to draw repo summary card", err)
380
-
http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
381
-
return
382
-
}
383
-
384
-
var imageBuffer bytes.Buffer
385
-
err = png.Encode(&imageBuffer, card.Img)
386
-
if err != nil {
387
-
log.Println("failed to encode repo summary card", err)
388
-
http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError)
389
-
return
390
-
}
391
-
392
-
imageBytes := imageBuffer.Bytes()
393
-
394
-
w.Header().Set("Content-Type", "image/png")
395
-
w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
396
-
w.WriteHeader(http.StatusOK)
397
-
_, err = w.Write(imageBytes)
398
-
if err != nil {
399
-
log.Println("failed to write repo summary card", err)
400
-
return
401
-
}
402
-
}
+1350
-65
appview/repo/repo.go
+1350
-65
appview/repo/repo.go
···
3
3
import (
4
4
"context"
5
5
"database/sql"
6
+
"encoding/json"
6
7
"errors"
7
8
"fmt"
9
+
"io"
10
+
"log"
8
11
"log/slog"
9
12
"net/http"
10
13
"net/url"
14
+
"path/filepath"
11
15
"slices"
16
+
"strconv"
12
17
"strings"
13
18
"time"
14
19
20
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
21
+
lexutil "github.com/bluesky-social/indigo/lex/util"
22
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
15
23
"tangled.org/core/api/tangled"
24
+
"tangled.org/core/appview/commitverify"
16
25
"tangled.org/core/appview/config"
17
26
"tangled.org/core/appview/db"
18
27
"tangled.org/core/appview/models"
19
28
"tangled.org/core/appview/notify"
20
29
"tangled.org/core/appview/oauth"
21
30
"tangled.org/core/appview/pages"
31
+
"tangled.org/core/appview/pages/markup"
22
32
"tangled.org/core/appview/reporesolver"
23
33
"tangled.org/core/appview/validator"
24
34
xrpcclient "tangled.org/core/appview/xrpcclient"
25
35
"tangled.org/core/eventconsumer"
26
36
"tangled.org/core/idresolver"
37
+
"tangled.org/core/patchutil"
27
38
"tangled.org/core/rbac"
28
39
"tangled.org/core/tid"
40
+
"tangled.org/core/types"
29
41
"tangled.org/core/xrpc/serviceauth"
30
42
31
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
32
-
atpclient "github.com/bluesky-social/indigo/atproto/client"
33
-
"github.com/bluesky-social/indigo/atproto/syntax"
34
-
lexutil "github.com/bluesky-social/indigo/lex/util"
35
43
securejoin "github.com/cyphar/filepath-securejoin"
36
44
"github.com/go-chi/chi/v5"
45
+
"github.com/go-git/go-git/v5/plumbing"
46
+
47
+
"github.com/bluesky-social/indigo/atproto/syntax"
37
48
)
38
49
39
50
type Repo struct {
···
78
89
}
79
90
}
80
91
92
+
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
93
+
ref := chi.URLParam(r, "ref")
94
+
ref, _ = url.PathUnescape(ref)
95
+
96
+
f, err := rp.repoResolver.Resolve(r)
97
+
if err != nil {
98
+
log.Println("failed to get repo and knot", err)
99
+
return
100
+
}
101
+
102
+
scheme := "http"
103
+
if !rp.config.Core.Dev {
104
+
scheme = "https"
105
+
}
106
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
107
+
xrpcc := &indigoxrpc.Client{
108
+
Host: host,
109
+
}
110
+
111
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
112
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
113
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
114
+
log.Println("failed to call XRPC repo.archive", xrpcerr)
115
+
rp.pages.Error503(w)
116
+
return
117
+
}
118
+
119
+
// Set headers for file download, just pass along whatever the knot specifies
120
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
121
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
122
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
123
+
w.Header().Set("Content-Type", "application/gzip")
124
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
125
+
126
+
// Write the archive data directly
127
+
w.Write(archiveBytes)
128
+
}
129
+
130
+
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
131
+
f, err := rp.repoResolver.Resolve(r)
132
+
if err != nil {
133
+
log.Println("failed to fully resolve repo", err)
134
+
return
135
+
}
136
+
137
+
page := 1
138
+
if r.URL.Query().Get("page") != "" {
139
+
page, err = strconv.Atoi(r.URL.Query().Get("page"))
140
+
if err != nil {
141
+
page = 1
142
+
}
143
+
}
144
+
145
+
ref := chi.URLParam(r, "ref")
146
+
ref, _ = url.PathUnescape(ref)
147
+
148
+
scheme := "http"
149
+
if !rp.config.Core.Dev {
150
+
scheme = "https"
151
+
}
152
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
153
+
xrpcc := &indigoxrpc.Client{
154
+
Host: host,
155
+
}
156
+
157
+
limit := int64(60)
158
+
cursor := ""
159
+
if page > 1 {
160
+
// Convert page number to cursor (offset)
161
+
offset := (page - 1) * int(limit)
162
+
cursor = strconv.Itoa(offset)
163
+
}
164
+
165
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
166
+
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
167
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
168
+
log.Println("failed to call XRPC repo.log", xrpcerr)
169
+
rp.pages.Error503(w)
170
+
return
171
+
}
172
+
173
+
var xrpcResp types.RepoLogResponse
174
+
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
175
+
log.Println("failed to decode XRPC response", err)
176
+
rp.pages.Error503(w)
177
+
return
178
+
}
179
+
180
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
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
+
}
186
+
187
+
tagMap := make(map[string][]string)
188
+
if tagBytes != nil {
189
+
var tagResp types.RepoTagsResponse
190
+
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
191
+
for _, tag := range tagResp.Tags {
192
+
tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name)
193
+
}
194
+
}
195
+
}
196
+
197
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
198
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
199
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
200
+
rp.pages.Error503(w)
201
+
return
202
+
}
203
+
204
+
if branchBytes != nil {
205
+
var branchResp types.RepoBranchesResponse
206
+
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
207
+
for _, branch := range branchResp.Branches {
208
+
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
209
+
}
210
+
}
211
+
}
212
+
213
+
user := rp.oauth.GetUser(r)
214
+
215
+
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
216
+
if err != nil {
217
+
log.Println("failed to fetch email to did mapping", err)
218
+
}
219
+
220
+
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
221
+
if err != nil {
222
+
log.Println(err)
223
+
}
224
+
225
+
repoInfo := f.RepoInfo(user)
226
+
227
+
var shas []string
228
+
for _, c := range xrpcResp.Commits {
229
+
shas = append(shas, c.Hash.String())
230
+
}
231
+
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
232
+
if err != nil {
233
+
log.Println(err)
234
+
// non-fatal
235
+
}
236
+
237
+
rp.pages.RepoLog(w, pages.RepoLogParams{
238
+
LoggedInUser: user,
239
+
TagMap: tagMap,
240
+
RepoInfo: repoInfo,
241
+
RepoLogResponse: xrpcResp,
242
+
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
243
+
VerifiedCommits: vc,
244
+
Pipelines: pipelines,
245
+
})
246
+
}
247
+
248
+
func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
249
+
f, err := rp.repoResolver.Resolve(r)
250
+
if err != nil {
251
+
log.Println("failed to get repo and knot", err)
252
+
w.WriteHeader(http.StatusBadRequest)
253
+
return
254
+
}
255
+
256
+
user := rp.oauth.GetUser(r)
257
+
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
258
+
RepoInfo: f.RepoInfo(user),
259
+
})
260
+
}
261
+
262
+
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
263
+
f, err := rp.repoResolver.Resolve(r)
264
+
if err != nil {
265
+
log.Println("failed to get repo and knot", err)
266
+
w.WriteHeader(http.StatusBadRequest)
267
+
return
268
+
}
269
+
270
+
repoAt := f.RepoAt()
271
+
rkey := repoAt.RecordKey().String()
272
+
if rkey == "" {
273
+
log.Println("invalid aturi for repo", err)
274
+
w.WriteHeader(http.StatusInternalServerError)
275
+
return
276
+
}
277
+
278
+
user := rp.oauth.GetUser(r)
279
+
280
+
switch r.Method {
281
+
case http.MethodGet:
282
+
rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
283
+
RepoInfo: f.RepoInfo(user),
284
+
})
285
+
return
286
+
case http.MethodPut:
287
+
newDescription := r.FormValue("description")
288
+
client, err := rp.oauth.AuthorizedClient(r)
289
+
if err != nil {
290
+
log.Println("failed to get client")
291
+
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
292
+
return
293
+
}
294
+
295
+
// optimistic update
296
+
err = db.UpdateDescription(rp.db, string(repoAt), newDescription)
297
+
if err != nil {
298
+
log.Println("failed to perferom update-description query", err)
299
+
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
300
+
return
301
+
}
302
+
303
+
newRepo := f.Repo
304
+
newRepo.Description = newDescription
305
+
record := newRepo.AsRecord()
306
+
307
+
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
308
+
//
309
+
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
310
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
311
+
if err != nil {
312
+
// failed to get record
313
+
rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
314
+
return
315
+
}
316
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
317
+
Collection: tangled.RepoNSID,
318
+
Repo: newRepo.Did,
319
+
Rkey: newRepo.Rkey,
320
+
SwapRecord: ex.Cid,
321
+
Record: &lexutil.LexiconTypeDecoder{
322
+
Val: &record,
323
+
},
324
+
})
325
+
326
+
if err != nil {
327
+
log.Println("failed to perferom update-description query", err)
328
+
// failed to get record
329
+
rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
330
+
return
331
+
}
332
+
333
+
newRepoInfo := f.RepoInfo(user)
334
+
newRepoInfo.Description = newDescription
335
+
336
+
rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
337
+
RepoInfo: newRepoInfo,
338
+
})
339
+
return
340
+
}
341
+
}
342
+
343
+
func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
344
+
f, err := rp.repoResolver.Resolve(r)
345
+
if err != nil {
346
+
log.Println("failed to fully resolve repo", err)
347
+
return
348
+
}
349
+
ref := chi.URLParam(r, "ref")
350
+
ref, _ = url.PathUnescape(ref)
351
+
352
+
var diffOpts types.DiffOpts
353
+
if d := r.URL.Query().Get("diff"); d == "split" {
354
+
diffOpts.Split = true
355
+
}
356
+
357
+
if !plumbing.IsHash(ref) {
358
+
rp.pages.Error404(w)
359
+
return
360
+
}
361
+
362
+
scheme := "http"
363
+
if !rp.config.Core.Dev {
364
+
scheme = "https"
365
+
}
366
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
367
+
xrpcc := &indigoxrpc.Client{
368
+
Host: host,
369
+
}
370
+
371
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
372
+
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
373
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
374
+
log.Println("failed to call XRPC repo.diff", xrpcerr)
375
+
rp.pages.Error503(w)
376
+
return
377
+
}
378
+
379
+
var result types.RepoCommitResponse
380
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
381
+
log.Println("failed to decode XRPC response", err)
382
+
rp.pages.Error503(w)
383
+
return
384
+
}
385
+
386
+
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
387
+
if err != nil {
388
+
log.Println("failed to get email to did mapping:", err)
389
+
}
390
+
391
+
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
392
+
if err != nil {
393
+
log.Println(err)
394
+
}
395
+
396
+
user := rp.oauth.GetUser(r)
397
+
repoInfo := f.RepoInfo(user)
398
+
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
399
+
if err != nil {
400
+
log.Println(err)
401
+
// non-fatal
402
+
}
403
+
var pipeline *models.Pipeline
404
+
if p, ok := pipelines[result.Diff.Commit.This]; ok {
405
+
pipeline = &p
406
+
}
407
+
408
+
rp.pages.RepoCommit(w, pages.RepoCommitParams{
409
+
LoggedInUser: user,
410
+
RepoInfo: f.RepoInfo(user),
411
+
RepoCommitResponse: result,
412
+
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
413
+
VerifiedCommit: vc,
414
+
Pipeline: pipeline,
415
+
DiffOpts: diffOpts,
416
+
})
417
+
}
418
+
419
+
func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
420
+
f, err := rp.repoResolver.Resolve(r)
421
+
if err != nil {
422
+
log.Println("failed to fully resolve repo", err)
423
+
return
424
+
}
425
+
426
+
ref := chi.URLParam(r, "ref")
427
+
ref, _ = url.PathUnescape(ref)
428
+
429
+
// if the tree path has a trailing slash, let's strip it
430
+
// so we don't 404
431
+
treePath := chi.URLParam(r, "*")
432
+
treePath, _ = url.PathUnescape(treePath)
433
+
treePath = strings.TrimSuffix(treePath, "/")
434
+
435
+
scheme := "http"
436
+
if !rp.config.Core.Dev {
437
+
scheme = "https"
438
+
}
439
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
440
+
xrpcc := &indigoxrpc.Client{
441
+
Host: host,
442
+
}
443
+
444
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
445
+
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
446
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
447
+
log.Println("failed to call XRPC repo.tree", xrpcerr)
448
+
rp.pages.Error503(w)
449
+
return
450
+
}
451
+
452
+
// Convert XRPC response to internal types.RepoTreeResponse
453
+
files := make([]types.NiceTree, len(xrpcResp.Files))
454
+
for i, xrpcFile := range xrpcResp.Files {
455
+
file := types.NiceTree{
456
+
Name: xrpcFile.Name,
457
+
Mode: xrpcFile.Mode,
458
+
Size: int64(xrpcFile.Size),
459
+
IsFile: xrpcFile.Is_file,
460
+
IsSubtree: xrpcFile.Is_subtree,
461
+
}
462
+
463
+
// Convert last commit info if present
464
+
if xrpcFile.Last_commit != nil {
465
+
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
466
+
file.LastCommit = &types.LastCommitInfo{
467
+
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
468
+
Message: xrpcFile.Last_commit.Message,
469
+
When: commitWhen,
470
+
}
471
+
}
472
+
473
+
files[i] = file
474
+
}
475
+
476
+
result := types.RepoTreeResponse{
477
+
Ref: xrpcResp.Ref,
478
+
Files: files,
479
+
}
480
+
481
+
if xrpcResp.Parent != nil {
482
+
result.Parent = *xrpcResp.Parent
483
+
}
484
+
if xrpcResp.Dotdot != nil {
485
+
result.DotDot = *xrpcResp.Dotdot
486
+
}
487
+
if xrpcResp.Readme != nil {
488
+
result.ReadmeFileName = xrpcResp.Readme.Filename
489
+
result.Readme = xrpcResp.Readme.Contents
490
+
}
491
+
492
+
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
493
+
// so we can safely redirect to the "parent" (which is the same file).
494
+
if len(result.Files) == 0 && result.Parent == treePath {
495
+
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
496
+
http.Redirect(w, r, redirectTo, http.StatusFound)
497
+
return
498
+
}
499
+
500
+
user := rp.oauth.GetUser(r)
501
+
502
+
var breadcrumbs [][]string
503
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
504
+
if treePath != "" {
505
+
for idx, elem := range strings.Split(treePath, "/") {
506
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
507
+
}
508
+
}
509
+
510
+
sortFiles(result.Files)
511
+
512
+
rp.pages.RepoTree(w, pages.RepoTreeParams{
513
+
LoggedInUser: user,
514
+
BreadCrumbs: breadcrumbs,
515
+
TreePath: treePath,
516
+
RepoInfo: f.RepoInfo(user),
517
+
RepoTreeResponse: result,
518
+
})
519
+
}
520
+
521
+
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
522
+
f, err := rp.repoResolver.Resolve(r)
523
+
if err != nil {
524
+
log.Println("failed to get repo and knot", err)
525
+
return
526
+
}
527
+
528
+
scheme := "http"
529
+
if !rp.config.Core.Dev {
530
+
scheme = "https"
531
+
}
532
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
533
+
xrpcc := &indigoxrpc.Client{
534
+
Host: host,
535
+
}
536
+
537
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
538
+
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
539
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
540
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
541
+
rp.pages.Error503(w)
542
+
return
543
+
}
544
+
545
+
var result types.RepoTagsResponse
546
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
547
+
log.Println("failed to decode XRPC response", err)
548
+
rp.pages.Error503(w)
549
+
return
550
+
}
551
+
552
+
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
553
+
if err != nil {
554
+
log.Println("failed grab artifacts", err)
555
+
return
556
+
}
557
+
558
+
// convert artifacts to map for easy UI building
559
+
artifactMap := make(map[plumbing.Hash][]models.Artifact)
560
+
for _, a := range artifacts {
561
+
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
562
+
}
563
+
564
+
var danglingArtifacts []models.Artifact
565
+
for _, a := range artifacts {
566
+
found := false
567
+
for _, t := range result.Tags {
568
+
if t.Tag != nil {
569
+
if t.Tag.Hash == a.Tag {
570
+
found = true
571
+
}
572
+
}
573
+
}
574
+
575
+
if !found {
576
+
danglingArtifacts = append(danglingArtifacts, a)
577
+
}
578
+
}
579
+
580
+
user := rp.oauth.GetUser(r)
581
+
rp.pages.RepoTags(w, pages.RepoTagsParams{
582
+
LoggedInUser: user,
583
+
RepoInfo: f.RepoInfo(user),
584
+
RepoTagsResponse: result,
585
+
ArtifactMap: artifactMap,
586
+
DanglingArtifacts: danglingArtifacts,
587
+
})
588
+
}
589
+
590
+
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
591
+
f, err := rp.repoResolver.Resolve(r)
592
+
if err != nil {
593
+
log.Println("failed to get repo and knot", err)
594
+
return
595
+
}
596
+
597
+
scheme := "http"
598
+
if !rp.config.Core.Dev {
599
+
scheme = "https"
600
+
}
601
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
602
+
xrpcc := &indigoxrpc.Client{
603
+
Host: host,
604
+
}
605
+
606
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
607
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
608
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
609
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
610
+
rp.pages.Error503(w)
611
+
return
612
+
}
613
+
614
+
var result types.RepoBranchesResponse
615
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
616
+
log.Println("failed to decode XRPC response", err)
617
+
rp.pages.Error503(w)
618
+
return
619
+
}
620
+
621
+
sortBranches(result.Branches)
622
+
623
+
user := rp.oauth.GetUser(r)
624
+
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
625
+
LoggedInUser: user,
626
+
RepoInfo: f.RepoInfo(user),
627
+
RepoBranchesResponse: result,
628
+
})
629
+
}
630
+
631
+
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
632
+
f, err := rp.repoResolver.Resolve(r)
633
+
if err != nil {
634
+
log.Println("failed to get repo and knot", err)
635
+
return
636
+
}
637
+
638
+
ref := chi.URLParam(r, "ref")
639
+
ref, _ = url.PathUnescape(ref)
640
+
641
+
filePath := chi.URLParam(r, "*")
642
+
filePath, _ = url.PathUnescape(filePath)
643
+
644
+
scheme := "http"
645
+
if !rp.config.Core.Dev {
646
+
scheme = "https"
647
+
}
648
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
649
+
xrpcc := &indigoxrpc.Client{
650
+
Host: host,
651
+
}
652
+
653
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
654
+
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
655
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
656
+
log.Println("failed to call XRPC repo.blob", xrpcerr)
657
+
rp.pages.Error503(w)
658
+
return
659
+
}
660
+
661
+
// Use XRPC response directly instead of converting to internal types
662
+
663
+
var breadcrumbs [][]string
664
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
665
+
if filePath != "" {
666
+
for idx, elem := range strings.Split(filePath, "/") {
667
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
668
+
}
669
+
}
670
+
671
+
showRendered := false
672
+
renderToggle := false
673
+
674
+
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
675
+
renderToggle = true
676
+
showRendered = r.URL.Query().Get("code") != "true"
677
+
}
678
+
679
+
var unsupported bool
680
+
var isImage bool
681
+
var isVideo bool
682
+
var contentSrc string
683
+
684
+
if resp.IsBinary != nil && *resp.IsBinary {
685
+
ext := strings.ToLower(filepath.Ext(resp.Path))
686
+
switch ext {
687
+
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
688
+
isImage = true
689
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
690
+
isVideo = true
691
+
default:
692
+
unsupported = true
693
+
}
694
+
695
+
// fetch the raw binary content using sh.tangled.repo.blob xrpc
696
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
697
+
698
+
baseURL := &url.URL{
699
+
Scheme: scheme,
700
+
Host: f.Knot,
701
+
Path: "/xrpc/sh.tangled.repo.blob",
702
+
}
703
+
query := baseURL.Query()
704
+
query.Set("repo", repoName)
705
+
query.Set("ref", ref)
706
+
query.Set("path", filePath)
707
+
query.Set("raw", "true")
708
+
baseURL.RawQuery = query.Encode()
709
+
blobURL := baseURL.String()
710
+
711
+
contentSrc = blobURL
712
+
if !rp.config.Core.Dev {
713
+
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
714
+
}
715
+
}
716
+
717
+
lines := 0
718
+
if resp.IsBinary == nil || !*resp.IsBinary {
719
+
lines = strings.Count(resp.Content, "\n") + 1
720
+
}
721
+
722
+
var sizeHint uint64
723
+
if resp.Size != nil {
724
+
sizeHint = uint64(*resp.Size)
725
+
} else {
726
+
sizeHint = uint64(len(resp.Content))
727
+
}
728
+
729
+
user := rp.oauth.GetUser(r)
730
+
731
+
// Determine if content is binary (dereference pointer)
732
+
isBinary := false
733
+
if resp.IsBinary != nil {
734
+
isBinary = *resp.IsBinary
735
+
}
736
+
737
+
rp.pages.RepoBlob(w, pages.RepoBlobParams{
738
+
LoggedInUser: user,
739
+
RepoInfo: f.RepoInfo(user),
740
+
BreadCrumbs: breadcrumbs,
741
+
ShowRendered: showRendered,
742
+
RenderToggle: renderToggle,
743
+
Unsupported: unsupported,
744
+
IsImage: isImage,
745
+
IsVideo: isVideo,
746
+
ContentSrc: contentSrc,
747
+
RepoBlob_Output: resp,
748
+
Contents: resp.Content,
749
+
Lines: lines,
750
+
SizeHint: sizeHint,
751
+
IsBinary: isBinary,
752
+
})
753
+
}
754
+
755
+
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
756
+
f, err := rp.repoResolver.Resolve(r)
757
+
if err != nil {
758
+
log.Println("failed to get repo and knot", err)
759
+
w.WriteHeader(http.StatusBadRequest)
760
+
return
761
+
}
762
+
763
+
ref := chi.URLParam(r, "ref")
764
+
ref, _ = url.PathUnescape(ref)
765
+
766
+
filePath := chi.URLParam(r, "*")
767
+
filePath, _ = url.PathUnescape(filePath)
768
+
769
+
scheme := "http"
770
+
if !rp.config.Core.Dev {
771
+
scheme = "https"
772
+
}
773
+
774
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
775
+
baseURL := &url.URL{
776
+
Scheme: scheme,
777
+
Host: f.Knot,
778
+
Path: "/xrpc/sh.tangled.repo.blob",
779
+
}
780
+
query := baseURL.Query()
781
+
query.Set("repo", repo)
782
+
query.Set("ref", ref)
783
+
query.Set("path", filePath)
784
+
query.Set("raw", "true")
785
+
baseURL.RawQuery = query.Encode()
786
+
blobURL := baseURL.String()
787
+
788
+
req, err := http.NewRequest("GET", blobURL, nil)
789
+
if err != nil {
790
+
log.Println("failed to create request", err)
791
+
return
792
+
}
793
+
794
+
// forward the If-None-Match header
795
+
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
796
+
req.Header.Set("If-None-Match", clientETag)
797
+
}
798
+
799
+
client := &http.Client{}
800
+
resp, err := client.Do(req)
801
+
if err != nil {
802
+
log.Println("failed to reach knotserver", err)
803
+
rp.pages.Error503(w)
804
+
return
805
+
}
806
+
defer resp.Body.Close()
807
+
808
+
// forward 304 not modified
809
+
if resp.StatusCode == http.StatusNotModified {
810
+
w.WriteHeader(http.StatusNotModified)
811
+
return
812
+
}
813
+
814
+
if resp.StatusCode != http.StatusOK {
815
+
log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
816
+
w.WriteHeader(resp.StatusCode)
817
+
_, _ = io.Copy(w, resp.Body)
818
+
return
819
+
}
820
+
821
+
contentType := resp.Header.Get("Content-Type")
822
+
body, err := io.ReadAll(resp.Body)
823
+
if err != nil {
824
+
log.Printf("error reading response body from knotserver: %v", err)
825
+
w.WriteHeader(http.StatusInternalServerError)
826
+
return
827
+
}
828
+
829
+
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
830
+
// serve all textual content as text/plain
831
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
832
+
w.Write(body)
833
+
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
834
+
// serve images and videos with their original content type
835
+
w.Header().Set("Content-Type", contentType)
836
+
w.Write(body)
837
+
} else {
838
+
w.WriteHeader(http.StatusUnsupportedMediaType)
839
+
w.Write([]byte("unsupported content type"))
840
+
return
841
+
}
842
+
}
843
+
844
+
// isTextualMimeType returns true if the MIME type represents textual content
845
+
// that should be served as text/plain
846
+
func isTextualMimeType(mimeType string) bool {
847
+
textualTypes := []string{
848
+
"application/json",
849
+
"application/xml",
850
+
"application/yaml",
851
+
"application/x-yaml",
852
+
"application/toml",
853
+
"application/javascript",
854
+
"application/ecmascript",
855
+
"message/",
856
+
}
857
+
858
+
return slices.Contains(textualTypes, mimeType)
859
+
}
860
+
81
861
// modify the spindle configured for this repo
82
862
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
83
863
user := rp.oauth.GetUser(r)
84
864
l := rp.logger.With("handler", "EditSpindle")
85
865
l = l.With("did", user.Did)
866
+
l = l.With("handle", user.Handle)
86
867
87
868
errorId := "operation-error"
88
869
fail := func(msg string, err error) {
···
135
916
return
136
917
}
137
918
138
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
919
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
139
920
if err != nil {
140
921
fail("Failed to update spindle, no record found on PDS.", err)
141
922
return
142
923
}
143
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
924
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
144
925
Collection: tangled.RepoNSID,
145
926
Repo: newRepo.Did,
146
927
Rkey: newRepo.Rkey,
···
170
951
user := rp.oauth.GetUser(r)
171
952
l := rp.logger.With("handler", "AddLabel")
172
953
l = l.With("did", user.Did)
954
+
l = l.With("handle", user.Handle)
173
955
174
956
f, err := rp.repoResolver.Resolve(r)
175
957
if err != nil {
···
238
1020
239
1021
// emit a labelRecord
240
1022
labelRecord := label.AsRecord()
241
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1023
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
242
1024
Collection: tangled.LabelDefinitionNSID,
243
1025
Repo: label.Did,
244
1026
Rkey: label.Rkey,
···
261
1043
newRepo.Labels = append(newRepo.Labels, aturi)
262
1044
repoRecord := newRepo.AsRecord()
263
1045
264
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1046
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
265
1047
if err != nil {
266
1048
fail("Failed to update labels, no record found on PDS.", err)
267
1049
return
268
1050
}
269
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1051
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
270
1052
Collection: tangled.RepoNSID,
271
1053
Repo: newRepo.Did,
272
1054
Rkey: newRepo.Rkey,
···
329
1111
user := rp.oauth.GetUser(r)
330
1112
l := rp.logger.With("handler", "DeleteLabel")
331
1113
l = l.With("did", user.Did)
1114
+
l = l.With("handle", user.Handle)
332
1115
333
1116
f, err := rp.repoResolver.Resolve(r)
334
1117
if err != nil {
···
358
1141
}
359
1142
360
1143
// delete label record from PDS
361
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
1144
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
362
1145
Collection: tangled.LabelDefinitionNSID,
363
1146
Repo: label.Did,
364
1147
Rkey: label.Rkey,
···
380
1163
newRepo.Labels = updated
381
1164
repoRecord := newRepo.AsRecord()
382
1165
383
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1166
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
384
1167
if err != nil {
385
1168
fail("Failed to update labels, no record found on PDS.", err)
386
1169
return
387
1170
}
388
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1171
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
389
1172
Collection: tangled.RepoNSID,
390
1173
Repo: newRepo.Did,
391
1174
Rkey: newRepo.Rkey,
···
437
1220
user := rp.oauth.GetUser(r)
438
1221
l := rp.logger.With("handler", "SubscribeLabel")
439
1222
l = l.With("did", user.Did)
1223
+
l = l.With("handle", user.Handle)
440
1224
441
1225
f, err := rp.repoResolver.Resolve(r)
442
1226
if err != nil {
···
477
1261
return
478
1262
}
479
1263
480
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1264
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
481
1265
if err != nil {
482
1266
fail("Failed to update labels, no record found on PDS.", err)
483
1267
return
484
1268
}
485
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1269
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
486
1270
Collection: tangled.RepoNSID,
487
1271
Repo: newRepo.Did,
488
1272
Rkey: newRepo.Rkey,
···
523
1307
user := rp.oauth.GetUser(r)
524
1308
l := rp.logger.With("handler", "UnsubscribeLabel")
525
1309
l = l.With("did", user.Did)
1310
+
l = l.With("handle", user.Handle)
526
1311
527
1312
f, err := rp.repoResolver.Resolve(r)
528
1313
if err != nil {
···
565
1350
return
566
1351
}
567
1352
568
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1353
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
569
1354
if err != nil {
570
1355
fail("Failed to update labels, no record found on PDS.", err)
571
1356
return
572
1357
}
573
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1358
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
574
1359
Collection: tangled.RepoNSID,
575
1360
Repo: newRepo.Did,
576
1361
Rkey: newRepo.Rkey,
···
616
1401
db.FilterContains("scope", subject.Collection().String()),
617
1402
)
618
1403
if err != nil {
619
-
l.Error("failed to fetch label defs", "err", err)
1404
+
log.Println("failed to fetch label defs", err)
620
1405
return
621
1406
}
622
1407
···
627
1412
628
1413
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
629
1414
if err != nil {
630
-
l.Error("failed to build label state", "err", err)
1415
+
log.Println("failed to build label state", err)
631
1416
return
632
1417
}
633
1418
state := states[subject]
···
664
1449
db.FilterContains("scope", subject.Collection().String()),
665
1450
)
666
1451
if err != nil {
667
-
l.Error("failed to fetch labels", "err", err)
1452
+
log.Println("failed to fetch labels", err)
668
1453
return
669
1454
}
670
1455
···
675
1460
676
1461
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
677
1462
if err != nil {
678
-
l.Error("failed to build label state", "err", err)
1463
+
log.Println("failed to build label state", err)
679
1464
return
680
1465
}
681
1466
state := states[subject]
···
694
1479
user := rp.oauth.GetUser(r)
695
1480
l := rp.logger.With("handler", "AddCollaborator")
696
1481
l = l.With("did", user.Did)
1482
+
l = l.With("handle", user.Handle)
697
1483
698
1484
f, err := rp.repoResolver.Resolve(r)
699
1485
if err != nil {
···
740
1526
currentUser := rp.oauth.GetUser(r)
741
1527
rkey := tid.TID()
742
1528
createdAt := time.Now()
743
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1529
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
744
1530
Collection: tangled.RepoCollaboratorNSID,
745
1531
Repo: currentUser.Did,
746
1532
Rkey: rkey,
···
822
1608
823
1609
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
824
1610
user := rp.oauth.GetUser(r)
825
-
l := rp.logger.With("handler", "DeleteRepo")
826
1611
827
1612
noticeId := "operation-error"
828
1613
f, err := rp.repoResolver.Resolve(r)
829
1614
if err != nil {
830
-
l.Error("failed to get repo and knot", "err", err)
1615
+
log.Println("failed to get repo and knot", err)
831
1616
return
832
1617
}
833
1618
834
1619
// remove record from pds
835
-
atpClient, err := rp.oauth.AuthorizedClient(r)
1620
+
xrpcClient, err := rp.oauth.AuthorizedClient(r)
836
1621
if err != nil {
837
-
l.Error("failed to get authorized client", "err", err)
1622
+
log.Println("failed to get authorized client", err)
838
1623
return
839
1624
}
840
-
_, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{
1625
+
_, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
841
1626
Collection: tangled.RepoNSID,
842
1627
Repo: user.Did,
843
1628
Rkey: f.Rkey,
844
1629
})
845
1630
if err != nil {
846
-
l.Error("failed to delete record", "err", err)
1631
+
log.Printf("failed to delete record: %s", err)
847
1632
rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
848
1633
return
849
1634
}
850
-
l.Info("removed repo record", "aturi", f.RepoAt().String())
1635
+
log.Println("removed repo record ", f.RepoAt().String())
851
1636
852
1637
client, err := rp.oauth.ServiceClient(
853
1638
r,
···
856
1641
oauth.WithDev(rp.config.Core.Dev),
857
1642
)
858
1643
if err != nil {
859
-
l.Error("failed to connect to knot server", "err", err)
1644
+
log.Println("failed to connect to knot server:", err)
860
1645
return
861
1646
}
862
1647
···
873
1658
rp.pages.Notice(w, noticeId, err.Error())
874
1659
return
875
1660
}
876
-
l.Info("deleted repo from knot")
1661
+
log.Println("deleted repo from knot")
877
1662
878
1663
tx, err := rp.db.BeginTx(r.Context(), nil)
879
1664
if err != nil {
880
-
l.Error("failed to start tx")
1665
+
log.Println("failed to start tx")
881
1666
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
882
1667
return
883
1668
}
···
885
1670
tx.Rollback()
886
1671
err = rp.enforcer.E.LoadPolicy()
887
1672
if err != nil {
888
-
l.Error("failed to rollback policies")
1673
+
log.Println("failed to rollback policies")
889
1674
}
890
1675
}()
891
1676
···
899
1684
did := c[0]
900
1685
rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
901
1686
}
902
-
l.Info("removed collaborators")
1687
+
log.Println("removed collaborators")
903
1688
904
1689
// remove repo RBAC
905
1690
err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
···
914
1699
rp.pages.Notice(w, noticeId, "Failed to update appview")
915
1700
return
916
1701
}
917
-
l.Info("removed repo from db")
1702
+
log.Println("removed repo from db")
918
1703
919
1704
err = tx.Commit()
920
1705
if err != nil {
921
-
l.Error("failed to commit changes", "err", err)
1706
+
log.Println("failed to commit changes", err)
922
1707
http.Error(w, err.Error(), http.StatusInternalServerError)
923
1708
return
924
1709
}
925
1710
926
1711
err = rp.enforcer.E.SavePolicy()
927
1712
if err != nil {
928
-
l.Error("failed to update ACLs", "err", err)
1713
+
log.Println("failed to update ACLs", err)
929
1714
http.Error(w, err.Error(), http.StatusInternalServerError)
930
1715
return
931
1716
}
···
933
1718
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
934
1719
}
935
1720
936
-
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
937
-
l := rp.logger.With("handler", "SyncRepoFork")
1721
+
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1722
+
f, err := rp.repoResolver.Resolve(r)
1723
+
if err != nil {
1724
+
log.Println("failed to get repo and knot", err)
1725
+
return
1726
+
}
1727
+
1728
+
noticeId := "operation-error"
1729
+
branch := r.FormValue("branch")
1730
+
if branch == "" {
1731
+
http.Error(w, "malformed form", http.StatusBadRequest)
1732
+
return
1733
+
}
1734
+
1735
+
client, err := rp.oauth.ServiceClient(
1736
+
r,
1737
+
oauth.WithService(f.Knot),
1738
+
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
1739
+
oauth.WithDev(rp.config.Core.Dev),
1740
+
)
1741
+
if err != nil {
1742
+
log.Println("failed to connect to knot server:", err)
1743
+
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
1744
+
return
1745
+
}
1746
+
1747
+
xe := tangled.RepoSetDefaultBranch(
1748
+
r.Context(),
1749
+
client,
1750
+
&tangled.RepoSetDefaultBranch_Input{
1751
+
Repo: f.RepoAt().String(),
1752
+
DefaultBranch: branch,
1753
+
},
1754
+
)
1755
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1756
+
log.Println("xrpc failed", "err", xe)
1757
+
rp.pages.Notice(w, noticeId, err.Error())
1758
+
return
1759
+
}
1760
+
1761
+
rp.pages.HxRefresh(w)
1762
+
}
1763
+
1764
+
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1765
+
user := rp.oauth.GetUser(r)
1766
+
l := rp.logger.With("handler", "Secrets")
1767
+
l = l.With("handle", user.Handle)
1768
+
l = l.With("did", user.Did)
1769
+
1770
+
f, err := rp.repoResolver.Resolve(r)
1771
+
if err != nil {
1772
+
log.Println("failed to get repo and knot", err)
1773
+
return
1774
+
}
1775
+
1776
+
if f.Spindle == "" {
1777
+
log.Println("empty spindle cannot add/rm secret", err)
1778
+
return
1779
+
}
1780
+
1781
+
lxm := tangled.RepoAddSecretNSID
1782
+
if r.Method == http.MethodDelete {
1783
+
lxm = tangled.RepoRemoveSecretNSID
1784
+
}
1785
+
1786
+
spindleClient, err := rp.oauth.ServiceClient(
1787
+
r,
1788
+
oauth.WithService(f.Spindle),
1789
+
oauth.WithLxm(lxm),
1790
+
oauth.WithExp(60),
1791
+
oauth.WithDev(rp.config.Core.Dev),
1792
+
)
1793
+
if err != nil {
1794
+
log.Println("failed to create spindle client", err)
1795
+
return
1796
+
}
1797
+
1798
+
key := r.FormValue("key")
1799
+
if key == "" {
1800
+
w.WriteHeader(http.StatusBadRequest)
1801
+
return
1802
+
}
1803
+
1804
+
switch r.Method {
1805
+
case http.MethodPut:
1806
+
errorId := "add-secret-error"
1807
+
1808
+
value := r.FormValue("value")
1809
+
if value == "" {
1810
+
w.WriteHeader(http.StatusBadRequest)
1811
+
return
1812
+
}
1813
+
1814
+
err = tangled.RepoAddSecret(
1815
+
r.Context(),
1816
+
spindleClient,
1817
+
&tangled.RepoAddSecret_Input{
1818
+
Repo: f.RepoAt().String(),
1819
+
Key: key,
1820
+
Value: value,
1821
+
},
1822
+
)
1823
+
if err != nil {
1824
+
l.Error("Failed to add secret.", "err", err)
1825
+
rp.pages.Notice(w, errorId, "Failed to add secret.")
1826
+
return
1827
+
}
1828
+
1829
+
case http.MethodDelete:
1830
+
errorId := "operation-error"
1831
+
1832
+
err = tangled.RepoRemoveSecret(
1833
+
r.Context(),
1834
+
spindleClient,
1835
+
&tangled.RepoRemoveSecret_Input{
1836
+
Repo: f.RepoAt().String(),
1837
+
Key: key,
1838
+
},
1839
+
)
1840
+
if err != nil {
1841
+
l.Error("Failed to delete secret.", "err", err)
1842
+
rp.pages.Notice(w, errorId, "Failed to delete secret.")
1843
+
return
1844
+
}
1845
+
}
1846
+
1847
+
rp.pages.HxRefresh(w)
1848
+
}
1849
+
1850
+
type tab = map[string]any
1851
+
1852
+
var (
1853
+
// would be great to have ordered maps right about now
1854
+
settingsTabs []tab = []tab{
1855
+
{"Name": "general", "Icon": "sliders-horizontal"},
1856
+
{"Name": "access", "Icon": "users"},
1857
+
{"Name": "pipelines", "Icon": "layers-2"},
1858
+
}
1859
+
)
1860
+
1861
+
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1862
+
tabVal := r.URL.Query().Get("tab")
1863
+
if tabVal == "" {
1864
+
tabVal = "general"
1865
+
}
1866
+
1867
+
switch tabVal {
1868
+
case "general":
1869
+
rp.generalSettings(w, r)
1870
+
1871
+
case "access":
1872
+
rp.accessSettings(w, r)
1873
+
1874
+
case "pipelines":
1875
+
rp.pipelineSettings(w, r)
1876
+
}
1877
+
}
1878
+
1879
+
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1880
+
f, err := rp.repoResolver.Resolve(r)
1881
+
user := rp.oauth.GetUser(r)
1882
+
1883
+
scheme := "http"
1884
+
if !rp.config.Core.Dev {
1885
+
scheme = "https"
1886
+
}
1887
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1888
+
xrpcc := &indigoxrpc.Client{
1889
+
Host: host,
1890
+
}
1891
+
1892
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1893
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1894
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1895
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
1896
+
rp.pages.Error503(w)
1897
+
return
1898
+
}
1899
+
1900
+
var result types.RepoBranchesResponse
1901
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1902
+
log.Println("failed to decode XRPC response", err)
1903
+
rp.pages.Error503(w)
1904
+
return
1905
+
}
1906
+
1907
+
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs()))
1908
+
if err != nil {
1909
+
log.Println("failed to fetch labels", err)
1910
+
rp.pages.Error503(w)
1911
+
return
1912
+
}
1913
+
1914
+
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
1915
+
if err != nil {
1916
+
log.Println("failed to fetch labels", err)
1917
+
rp.pages.Error503(w)
1918
+
return
1919
+
}
1920
+
// remove default labels from the labels list, if present
1921
+
defaultLabelMap := make(map[string]bool)
1922
+
for _, dl := range defaultLabels {
1923
+
defaultLabelMap[dl.AtUri().String()] = true
1924
+
}
1925
+
n := 0
1926
+
for _, l := range labels {
1927
+
if !defaultLabelMap[l.AtUri().String()] {
1928
+
labels[n] = l
1929
+
n++
1930
+
}
1931
+
}
1932
+
labels = labels[:n]
1933
+
1934
+
subscribedLabels := make(map[string]struct{})
1935
+
for _, l := range f.Repo.Labels {
1936
+
subscribedLabels[l] = struct{}{}
1937
+
}
1938
+
1939
+
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
1940
+
// if all default labels are subbed, show the "unsubscribe all" button
1941
+
shouldSubscribeAll := false
1942
+
for _, dl := range defaultLabels {
1943
+
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
1944
+
// one of the default labels is not subscribed to
1945
+
shouldSubscribeAll = true
1946
+
break
1947
+
}
1948
+
}
1949
+
1950
+
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1951
+
LoggedInUser: user,
1952
+
RepoInfo: f.RepoInfo(user),
1953
+
Branches: result.Branches,
1954
+
Labels: labels,
1955
+
DefaultLabels: defaultLabels,
1956
+
SubscribedLabels: subscribedLabels,
1957
+
ShouldSubscribeAll: shouldSubscribeAll,
1958
+
Tabs: settingsTabs,
1959
+
Tab: "general",
1960
+
})
1961
+
}
1962
+
1963
+
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
1964
+
f, err := rp.repoResolver.Resolve(r)
1965
+
user := rp.oauth.GetUser(r)
1966
+
1967
+
repoCollaborators, err := f.Collaborators(r.Context())
1968
+
if err != nil {
1969
+
log.Println("failed to get collaborators", err)
1970
+
}
1971
+
1972
+
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
1973
+
LoggedInUser: user,
1974
+
RepoInfo: f.RepoInfo(user),
1975
+
Tabs: settingsTabs,
1976
+
Tab: "access",
1977
+
Collaborators: repoCollaborators,
1978
+
})
1979
+
}
1980
+
1981
+
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
1982
+
f, err := rp.repoResolver.Resolve(r)
1983
+
user := rp.oauth.GetUser(r)
1984
+
1985
+
// all spindles that the repo owner is a member of
1986
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1987
+
if err != nil {
1988
+
log.Println("failed to fetch spindles", err)
1989
+
return
1990
+
}
1991
+
1992
+
var secrets []*tangled.RepoListSecrets_Secret
1993
+
if f.Spindle != "" {
1994
+
if spindleClient, err := rp.oauth.ServiceClient(
1995
+
r,
1996
+
oauth.WithService(f.Spindle),
1997
+
oauth.WithLxm(tangled.RepoListSecretsNSID),
1998
+
oauth.WithExp(60),
1999
+
oauth.WithDev(rp.config.Core.Dev),
2000
+
); err != nil {
2001
+
log.Println("failed to create spindle client", err)
2002
+
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
2003
+
log.Println("failed to fetch secrets", err)
2004
+
} else {
2005
+
secrets = resp.Secrets
2006
+
}
2007
+
}
2008
+
2009
+
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
2010
+
return strings.Compare(a.Key, b.Key)
2011
+
})
2012
+
2013
+
var dids []string
2014
+
for _, s := range secrets {
2015
+
dids = append(dids, s.CreatedBy)
2016
+
}
2017
+
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
938
2018
2019
+
// convert to a more manageable form
2020
+
var niceSecret []map[string]any
2021
+
for id, s := range secrets {
2022
+
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
2023
+
niceSecret = append(niceSecret, map[string]any{
2024
+
"Id": id,
2025
+
"Key": s.Key,
2026
+
"CreatedAt": when,
2027
+
"CreatedBy": resolvedIdents[id].Handle.String(),
2028
+
})
2029
+
}
2030
+
2031
+
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
2032
+
LoggedInUser: user,
2033
+
RepoInfo: f.RepoInfo(user),
2034
+
Tabs: settingsTabs,
2035
+
Tab: "pipelines",
2036
+
Spindles: spindles,
2037
+
CurrentSpindle: f.Spindle,
2038
+
Secrets: niceSecret,
2039
+
})
2040
+
}
2041
+
2042
+
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
939
2043
ref := chi.URLParam(r, "ref")
940
2044
ref, _ = url.PathUnescape(ref)
941
2045
942
2046
user := rp.oauth.GetUser(r)
943
2047
f, err := rp.repoResolver.Resolve(r)
944
2048
if err != nil {
945
-
l.Error("failed to resolve source repo", "err", err)
2049
+
log.Printf("failed to resolve source repo: %v", err)
946
2050
return
947
2051
}
948
2052
···
986
2090
}
987
2091
988
2092
func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
989
-
l := rp.logger.With("handler", "ForkRepo")
990
-
991
2093
user := rp.oauth.GetUser(r)
992
2094
f, err := rp.repoResolver.Resolve(r)
993
2095
if err != nil {
994
-
l.Error("failed to resolve source repo", "err", err)
2096
+
log.Printf("failed to resolve source repo: %v", err)
995
2097
return
996
2098
}
997
2099
···
1027
2129
}
1028
2130
1029
2131
// choose a name for a fork
1030
-
forkName := r.FormValue("repo_name")
1031
-
if forkName == "" {
1032
-
rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
1033
-
return
1034
-
}
1035
-
2132
+
forkName := f.Name
1036
2133
// this check is *only* to see if the forked repo name already exists
1037
2134
// in the user's account.
1038
2135
existingRepo, err := db.GetRepo(
1039
2136
rp.db,
1040
2137
db.FilterEq("did", user.Did),
1041
-
db.FilterEq("name", forkName),
2138
+
db.FilterEq("name", f.Name),
1042
2139
)
1043
2140
if err != nil {
1044
-
if !errors.Is(err, sql.ErrNoRows) {
1045
-
l.Error("error fetching existing repo from db", "err", err)
2141
+
if errors.Is(err, sql.ErrNoRows) {
2142
+
// no existing repo with this name found, we can use the name as is
2143
+
} else {
2144
+
log.Println("error fetching existing repo from db", "err", err)
1046
2145
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1047
2146
return
1048
2147
}
1049
2148
} else if existingRepo != nil {
1050
-
// repo with this name already exists
1051
-
rp.pages.Notice(w, "repo", "A repository with this name already exists.")
1052
-
return
2149
+
// repo with this name already exists, append random string
2150
+
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1053
2151
}
1054
2152
l = l.With("forkName", forkName)
1055
2153
···
1073
2171
Source: sourceAt,
1074
2172
Description: f.Repo.Description,
1075
2173
Created: time.Now(),
1076
-
Labels: rp.config.Label.DefaultLabelDefs,
2174
+
Labels: models.DefaultLabelDefs(),
1077
2175
}
1078
2176
record := repo.AsRecord()
1079
2177
1080
-
atpClient, err := rp.oauth.AuthorizedClient(r)
2178
+
xrpcClient, err := rp.oauth.AuthorizedClient(r)
1081
2179
if err != nil {
1082
2180
l.Error("failed to create xrpcclient", "err", err)
1083
2181
rp.pages.Notice(w, "repo", "Failed to fork repository.")
1084
2182
return
1085
2183
}
1086
2184
1087
-
atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
2185
+
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1088
2186
Collection: tangled.RepoNSID,
1089
2187
Repo: user.Did,
1090
2188
Rkey: rkey,
···
1116
2214
rollback := func() {
1117
2215
err1 := tx.Rollback()
1118
2216
err2 := rp.enforcer.E.LoadPolicy()
1119
-
err3 := rollbackRecord(context.Background(), aturi, atpClient)
2217
+
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
1120
2218
1121
2219
// ignore txn complete errors, this is okay
1122
2220
if errors.Is(err1, sql.ErrTxDone) {
···
1157
2255
1158
2256
err = db.AddRepo(tx, repo)
1159
2257
if err != nil {
1160
-
l.Error("failed to AddRepo", "err", err)
2258
+
log.Println(err)
1161
2259
rp.pages.Notice(w, "repo", "Failed to save repository information.")
1162
2260
return
1163
2261
}
···
1166
2264
p, _ := securejoin.SecureJoin(user.Did, forkName)
1167
2265
err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
1168
2266
if err != nil {
1169
-
l.Error("failed to add ACLs", "err", err)
2267
+
log.Println(err)
1170
2268
rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1171
2269
return
1172
2270
}
1173
2271
1174
2272
err = tx.Commit()
1175
2273
if err != nil {
1176
-
l.Error("failed to commit changes", "err", err)
2274
+
log.Println("failed to commit changes", err)
1177
2275
http.Error(w, err.Error(), http.StatusInternalServerError)
1178
2276
return
1179
2277
}
1180
2278
1181
2279
err = rp.enforcer.E.SavePolicy()
1182
2280
if err != nil {
1183
-
l.Error("failed to update ACLs", "err", err)
2281
+
log.Println("failed to update ACLs", err)
1184
2282
http.Error(w, err.Error(), http.StatusInternalServerError)
1185
2283
return
1186
2284
}
···
1189
2287
aturi = ""
1190
2288
1191
2289
rp.notifier.NewRepo(r.Context(), repo)
1192
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName))
2290
+
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1193
2291
}
1194
2292
}
1195
2293
1196
2294
// this is used to rollback changes made to the PDS
1197
2295
//
1198
2296
// it is a no-op if the provided ATURI is empty
1199
-
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
2297
+
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
1200
2298
if aturi == "" {
1201
2299
return nil
1202
2300
}
···
1207
2305
repo := parsed.Authority().String()
1208
2306
rkey := parsed.RecordKey().String()
1209
2307
1210
-
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
2308
+
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
1211
2309
Collection: collection,
1212
2310
Repo: repo,
1213
2311
Rkey: rkey,
1214
2312
})
1215
2313
return err
1216
2314
}
2315
+
2316
+
func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
2317
+
user := rp.oauth.GetUser(r)
2318
+
f, err := rp.repoResolver.Resolve(r)
2319
+
if err != nil {
2320
+
log.Println("failed to get repo and knot", err)
2321
+
return
2322
+
}
2323
+
2324
+
scheme := "http"
2325
+
if !rp.config.Core.Dev {
2326
+
scheme = "https"
2327
+
}
2328
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2329
+
xrpcc := &indigoxrpc.Client{
2330
+
Host: host,
2331
+
}
2332
+
2333
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2334
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2335
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2336
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
2337
+
rp.pages.Error503(w)
2338
+
return
2339
+
}
2340
+
2341
+
var branchResult types.RepoBranchesResponse
2342
+
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
2343
+
log.Println("failed to decode XRPC branches response", err)
2344
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2345
+
return
2346
+
}
2347
+
branches := branchResult.Branches
2348
+
2349
+
sortBranches(branches)
2350
+
2351
+
var defaultBranch string
2352
+
for _, b := range branches {
2353
+
if b.IsDefault {
2354
+
defaultBranch = b.Name
2355
+
}
2356
+
}
2357
+
2358
+
base := defaultBranch
2359
+
head := defaultBranch
2360
+
2361
+
params := r.URL.Query()
2362
+
queryBase := params.Get("base")
2363
+
queryHead := params.Get("head")
2364
+
if queryBase != "" {
2365
+
base = queryBase
2366
+
}
2367
+
if queryHead != "" {
2368
+
head = queryHead
2369
+
}
2370
+
2371
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2372
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2373
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
2374
+
rp.pages.Error503(w)
2375
+
return
2376
+
}
2377
+
2378
+
var tags types.RepoTagsResponse
2379
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2380
+
log.Println("failed to decode XRPC tags response", err)
2381
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2382
+
return
2383
+
}
2384
+
2385
+
repoinfo := f.RepoInfo(user)
2386
+
2387
+
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
2388
+
LoggedInUser: user,
2389
+
RepoInfo: repoinfo,
2390
+
Branches: branches,
2391
+
Tags: tags.Tags,
2392
+
Base: base,
2393
+
Head: head,
2394
+
})
2395
+
}
2396
+
2397
+
func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
2398
+
user := rp.oauth.GetUser(r)
2399
+
f, err := rp.repoResolver.Resolve(r)
2400
+
if err != nil {
2401
+
log.Println("failed to get repo and knot", err)
2402
+
return
2403
+
}
2404
+
2405
+
var diffOpts types.DiffOpts
2406
+
if d := r.URL.Query().Get("diff"); d == "split" {
2407
+
diffOpts.Split = true
2408
+
}
2409
+
2410
+
// if user is navigating to one of
2411
+
// /compare/{base}/{head}
2412
+
// /compare/{base}...{head}
2413
+
base := chi.URLParam(r, "base")
2414
+
head := chi.URLParam(r, "head")
2415
+
if base == "" && head == "" {
2416
+
rest := chi.URLParam(r, "*") // master...feature/xyz
2417
+
parts := strings.SplitN(rest, "...", 2)
2418
+
if len(parts) == 2 {
2419
+
base = parts[0]
2420
+
head = parts[1]
2421
+
}
2422
+
}
2423
+
2424
+
base, _ = url.PathUnescape(base)
2425
+
head, _ = url.PathUnescape(head)
2426
+
2427
+
if base == "" || head == "" {
2428
+
log.Printf("invalid comparison")
2429
+
rp.pages.Error404(w)
2430
+
return
2431
+
}
2432
+
2433
+
scheme := "http"
2434
+
if !rp.config.Core.Dev {
2435
+
scheme = "https"
2436
+
}
2437
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
2438
+
xrpcc := &indigoxrpc.Client{
2439
+
Host: host,
2440
+
}
2441
+
2442
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2443
+
2444
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2445
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2446
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
2447
+
rp.pages.Error503(w)
2448
+
return
2449
+
}
2450
+
2451
+
var branches types.RepoBranchesResponse
2452
+
if err := json.Unmarshal(branchBytes, &branches); err != nil {
2453
+
log.Println("failed to decode XRPC branches response", err)
2454
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2455
+
return
2456
+
}
2457
+
2458
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2459
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2460
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
2461
+
rp.pages.Error503(w)
2462
+
return
2463
+
}
2464
+
2465
+
var tags types.RepoTagsResponse
2466
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
2467
+
log.Println("failed to decode XRPC tags response", err)
2468
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2469
+
return
2470
+
}
2471
+
2472
+
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
2473
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2474
+
log.Println("failed to call XRPC repo.compare", xrpcerr)
2475
+
rp.pages.Error503(w)
2476
+
return
2477
+
}
2478
+
2479
+
var formatPatch types.RepoFormatPatchResponse
2480
+
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
2481
+
log.Println("failed to decode XRPC compare response", err)
2482
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2483
+
return
2484
+
}
2485
+
2486
+
diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
2487
+
2488
+
repoinfo := f.RepoInfo(user)
2489
+
2490
+
rp.pages.RepoCompare(w, pages.RepoCompareParams{
2491
+
LoggedInUser: user,
2492
+
RepoInfo: repoinfo,
2493
+
Branches: branches.Branches,
2494
+
Tags: tags.Tags,
2495
+
Base: base,
2496
+
Head: head,
2497
+
Diff: &diff,
2498
+
DiffOpts: diffOpts,
2499
+
})
2500
+
2501
+
}
+37
-2
appview/repo/repo_util.go
+37
-2
appview/repo/repo_util.go
···
1
1
package repo
2
2
3
3
import (
4
+
"context"
4
5
"crypto/rand"
6
+
"fmt"
5
7
"math/big"
6
8
"slices"
7
9
"sort"
···
17
19
18
20
func sortFiles(files []types.NiceTree) {
19
21
sort.Slice(files, func(i, j int) bool {
20
-
iIsFile := files[i].IsFile()
21
-
jIsFile := files[j].IsFile()
22
+
iIsFile := files[i].IsFile
23
+
jIsFile := files[j].IsFile
22
24
if iIsFile != jIsFile {
23
25
return !iIsFile
24
26
}
···
88
90
}
89
91
90
92
return
93
+
}
94
+
95
+
// emailToDidOrHandle takes an emailToDidMap from db.GetEmailToDid
96
+
// and resolves all dids to handles and returns a new map[string]string
97
+
func emailToDidOrHandle(r *Repo, emailToDidMap map[string]string) map[string]string {
98
+
if emailToDidMap == nil {
99
+
return nil
100
+
}
101
+
102
+
var dids []string
103
+
for _, v := range emailToDidMap {
104
+
dids = append(dids, v)
105
+
}
106
+
resolvedIdents := r.idResolver.ResolveIdents(context.Background(), dids)
107
+
108
+
didHandleMap := make(map[string]string)
109
+
for _, identity := range resolvedIdents {
110
+
if !identity.Handle.IsInvalidHandle() {
111
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
112
+
} else {
113
+
didHandleMap[identity.DID.String()] = identity.DID.String()
114
+
}
115
+
}
116
+
117
+
// Create map of email to didOrHandle for commit display
118
+
emailToDidOrHandle := make(map[string]string)
119
+
for email, did := range emailToDidMap {
120
+
if didOrHandle, ok := didHandleMap[did]; ok {
121
+
emailToDidOrHandle[email] = didOrHandle
122
+
}
123
+
}
124
+
125
+
return emailToDidOrHandle
91
126
}
92
127
93
128
func randomString(n int) string {
+19
-16
appview/repo/router.go
+19
-16
appview/repo/router.go
···
9
9
10
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
11
r := chi.NewRouter()
12
-
r.Get("/", rp.Index)
13
-
r.Get("/opengraph", rp.Opengraph)
14
-
r.Get("/feed.atom", rp.AtomFeed)
15
-
r.Get("/commits/{ref}", rp.Log)
12
+
r.Get("/", rp.RepoIndex)
13
+
r.Get("/feed.atom", rp.RepoAtomFeed)
14
+
r.Get("/commits/{ref}", rp.RepoLog)
16
15
r.Route("/tree/{ref}", func(r chi.Router) {
17
-
r.Get("/", rp.Index)
18
-
r.Get("/*", rp.Tree)
16
+
r.Get("/", rp.RepoIndex)
17
+
r.Get("/*", rp.RepoTree)
19
18
})
20
-
r.Get("/commit/{ref}", rp.Commit)
21
-
r.Get("/branches", rp.Branches)
22
-
r.Delete("/branches", rp.DeleteBranch)
19
+
r.Get("/commit/{ref}", rp.RepoCommit)
20
+
r.Get("/branches", rp.RepoBranches)
23
21
r.Route("/tags", func(r chi.Router) {
24
-
r.Get("/", rp.Tags)
22
+
r.Get("/", rp.RepoTags)
25
23
r.Route("/{tag}", func(r chi.Router) {
26
24
r.Get("/download/{file}", rp.DownloadArtifact)
27
25
···
37
35
})
38
36
})
39
37
})
40
-
r.Get("/blob/{ref}/*", rp.Blob)
38
+
r.Get("/blob/{ref}/*", rp.RepoBlob)
41
39
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
42
40
43
41
// intentionally doesn't use /* as this isn't
···
54
52
})
55
53
56
54
r.Route("/compare", func(r chi.Router) {
57
-
r.Get("/", rp.CompareNew) // start an new comparison
55
+
r.Get("/", rp.RepoCompareNew) // start an new comparison
58
56
59
57
// we have to wildcard here since we want to support GitHub's compare syntax
60
58
// /compare/{ref1}...{ref2}
61
59
// for example:
62
60
// /compare/master...some/feature
63
61
// /compare/master...example.com:another/feature <- this is a fork
64
-
r.Get("/{base}/{head}", rp.Compare)
65
-
r.Get("/*", rp.Compare)
62
+
r.Get("/{base}/{head}", rp.RepoCompare)
63
+
r.Get("/*", rp.RepoCompare)
66
64
})
67
65
68
66
// label panel in issues/pulls/discussions/tasks
···
74
72
// settings routes, needs auth
75
73
r.Group(func(r chi.Router) {
76
74
r.Use(middleware.AuthMiddleware(rp.oauth))
75
+
// repo description can only be edited by owner
76
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/description", func(r chi.Router) {
77
+
r.Put("/", rp.RepoDescription)
78
+
r.Get("/", rp.RepoDescription)
79
+
r.Get("/edit", rp.RepoDescriptionEdit)
80
+
})
77
81
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
78
-
r.Get("/", rp.Settings)
79
-
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings)
82
+
r.Get("/", rp.RepoSettings)
80
83
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
81
84
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
82
85
r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
-442
appview/repo/settings.go
-442
appview/repo/settings.go
···
1
-
package repo
2
-
3
-
import (
4
-
"encoding/json"
5
-
"fmt"
6
-
"net/http"
7
-
"slices"
8
-
"strings"
9
-
"time"
10
-
11
-
"tangled.org/core/api/tangled"
12
-
"tangled.org/core/appview/db"
13
-
"tangled.org/core/appview/oauth"
14
-
"tangled.org/core/appview/pages"
15
-
xrpcclient "tangled.org/core/appview/xrpcclient"
16
-
"tangled.org/core/types"
17
-
18
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
19
-
lexutil "github.com/bluesky-social/indigo/lex/util"
20
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
21
-
)
22
-
23
-
type tab = map[string]any
24
-
25
-
var (
26
-
// would be great to have ordered maps right about now
27
-
settingsTabs []tab = []tab{
28
-
{"Name": "general", "Icon": "sliders-horizontal"},
29
-
{"Name": "access", "Icon": "users"},
30
-
{"Name": "pipelines", "Icon": "layers-2"},
31
-
}
32
-
)
33
-
34
-
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
35
-
l := rp.logger.With("handler", "SetDefaultBranch")
36
-
37
-
f, err := rp.repoResolver.Resolve(r)
38
-
if err != nil {
39
-
l.Error("failed to get repo and knot", "err", err)
40
-
return
41
-
}
42
-
43
-
noticeId := "operation-error"
44
-
branch := r.FormValue("branch")
45
-
if branch == "" {
46
-
http.Error(w, "malformed form", http.StatusBadRequest)
47
-
return
48
-
}
49
-
50
-
client, err := rp.oauth.ServiceClient(
51
-
r,
52
-
oauth.WithService(f.Knot),
53
-
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
54
-
oauth.WithDev(rp.config.Core.Dev),
55
-
)
56
-
if err != nil {
57
-
l.Error("failed to connect to knot server", "err", err)
58
-
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
59
-
return
60
-
}
61
-
62
-
xe := tangled.RepoSetDefaultBranch(
63
-
r.Context(),
64
-
client,
65
-
&tangled.RepoSetDefaultBranch_Input{
66
-
Repo: f.RepoAt().String(),
67
-
DefaultBranch: branch,
68
-
},
69
-
)
70
-
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
71
-
l.Error("xrpc failed", "err", xe)
72
-
rp.pages.Notice(w, noticeId, err.Error())
73
-
return
74
-
}
75
-
76
-
rp.pages.HxRefresh(w)
77
-
}
78
-
79
-
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
80
-
user := rp.oauth.GetUser(r)
81
-
l := rp.logger.With("handler", "Secrets")
82
-
l = l.With("did", user.Did)
83
-
84
-
f, err := rp.repoResolver.Resolve(r)
85
-
if err != nil {
86
-
l.Error("failed to get repo and knot", "err", err)
87
-
return
88
-
}
89
-
90
-
if f.Spindle == "" {
91
-
l.Error("empty spindle cannot add/rm secret", "err", err)
92
-
return
93
-
}
94
-
95
-
lxm := tangled.RepoAddSecretNSID
96
-
if r.Method == http.MethodDelete {
97
-
lxm = tangled.RepoRemoveSecretNSID
98
-
}
99
-
100
-
spindleClient, err := rp.oauth.ServiceClient(
101
-
r,
102
-
oauth.WithService(f.Spindle),
103
-
oauth.WithLxm(lxm),
104
-
oauth.WithExp(60),
105
-
oauth.WithDev(rp.config.Core.Dev),
106
-
)
107
-
if err != nil {
108
-
l.Error("failed to create spindle client", "err", err)
109
-
return
110
-
}
111
-
112
-
key := r.FormValue("key")
113
-
if key == "" {
114
-
w.WriteHeader(http.StatusBadRequest)
115
-
return
116
-
}
117
-
118
-
switch r.Method {
119
-
case http.MethodPut:
120
-
errorId := "add-secret-error"
121
-
122
-
value := r.FormValue("value")
123
-
if value == "" {
124
-
w.WriteHeader(http.StatusBadRequest)
125
-
return
126
-
}
127
-
128
-
err = tangled.RepoAddSecret(
129
-
r.Context(),
130
-
spindleClient,
131
-
&tangled.RepoAddSecret_Input{
132
-
Repo: f.RepoAt().String(),
133
-
Key: key,
134
-
Value: value,
135
-
},
136
-
)
137
-
if err != nil {
138
-
l.Error("Failed to add secret.", "err", err)
139
-
rp.pages.Notice(w, errorId, "Failed to add secret.")
140
-
return
141
-
}
142
-
143
-
case http.MethodDelete:
144
-
errorId := "operation-error"
145
-
146
-
err = tangled.RepoRemoveSecret(
147
-
r.Context(),
148
-
spindleClient,
149
-
&tangled.RepoRemoveSecret_Input{
150
-
Repo: f.RepoAt().String(),
151
-
Key: key,
152
-
},
153
-
)
154
-
if err != nil {
155
-
l.Error("Failed to delete secret.", "err", err)
156
-
rp.pages.Notice(w, errorId, "Failed to delete secret.")
157
-
return
158
-
}
159
-
}
160
-
161
-
rp.pages.HxRefresh(w)
162
-
}
163
-
164
-
func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) {
165
-
tabVal := r.URL.Query().Get("tab")
166
-
if tabVal == "" {
167
-
tabVal = "general"
168
-
}
169
-
170
-
switch tabVal {
171
-
case "general":
172
-
rp.generalSettings(w, r)
173
-
174
-
case "access":
175
-
rp.accessSettings(w, r)
176
-
177
-
case "pipelines":
178
-
rp.pipelineSettings(w, r)
179
-
}
180
-
}
181
-
182
-
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
183
-
l := rp.logger.With("handler", "generalSettings")
184
-
185
-
f, err := rp.repoResolver.Resolve(r)
186
-
user := rp.oauth.GetUser(r)
187
-
188
-
scheme := "http"
189
-
if !rp.config.Core.Dev {
190
-
scheme = "https"
191
-
}
192
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
193
-
xrpcc := &indigoxrpc.Client{
194
-
Host: host,
195
-
}
196
-
197
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
198
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
199
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
200
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
201
-
rp.pages.Error503(w)
202
-
return
203
-
}
204
-
205
-
var result types.RepoBranchesResponse
206
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
207
-
l.Error("failed to decode XRPC response", "err", err)
208
-
rp.pages.Error503(w)
209
-
return
210
-
}
211
-
212
-
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs))
213
-
if err != nil {
214
-
l.Error("failed to fetch labels", "err", err)
215
-
rp.pages.Error503(w)
216
-
return
217
-
}
218
-
219
-
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
220
-
if err != nil {
221
-
l.Error("failed to fetch labels", "err", err)
222
-
rp.pages.Error503(w)
223
-
return
224
-
}
225
-
// remove default labels from the labels list, if present
226
-
defaultLabelMap := make(map[string]bool)
227
-
for _, dl := range defaultLabels {
228
-
defaultLabelMap[dl.AtUri().String()] = true
229
-
}
230
-
n := 0
231
-
for _, l := range labels {
232
-
if !defaultLabelMap[l.AtUri().String()] {
233
-
labels[n] = l
234
-
n++
235
-
}
236
-
}
237
-
labels = labels[:n]
238
-
239
-
subscribedLabels := make(map[string]struct{})
240
-
for _, l := range f.Repo.Labels {
241
-
subscribedLabels[l] = struct{}{}
242
-
}
243
-
244
-
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
245
-
// if all default labels are subbed, show the "unsubscribe all" button
246
-
shouldSubscribeAll := false
247
-
for _, dl := range defaultLabels {
248
-
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
249
-
// one of the default labels is not subscribed to
250
-
shouldSubscribeAll = true
251
-
break
252
-
}
253
-
}
254
-
255
-
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
256
-
LoggedInUser: user,
257
-
RepoInfo: f.RepoInfo(user),
258
-
Branches: result.Branches,
259
-
Labels: labels,
260
-
DefaultLabels: defaultLabels,
261
-
SubscribedLabels: subscribedLabels,
262
-
ShouldSubscribeAll: shouldSubscribeAll,
263
-
Tabs: settingsTabs,
264
-
Tab: "general",
265
-
})
266
-
}
267
-
268
-
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
269
-
l := rp.logger.With("handler", "accessSettings")
270
-
271
-
f, err := rp.repoResolver.Resolve(r)
272
-
user := rp.oauth.GetUser(r)
273
-
274
-
repoCollaborators, err := f.Collaborators(r.Context())
275
-
if err != nil {
276
-
l.Error("failed to get collaborators", "err", err)
277
-
}
278
-
279
-
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
280
-
LoggedInUser: user,
281
-
RepoInfo: f.RepoInfo(user),
282
-
Tabs: settingsTabs,
283
-
Tab: "access",
284
-
Collaborators: repoCollaborators,
285
-
})
286
-
}
287
-
288
-
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
289
-
l := rp.logger.With("handler", "pipelineSettings")
290
-
291
-
f, err := rp.repoResolver.Resolve(r)
292
-
user := rp.oauth.GetUser(r)
293
-
294
-
// all spindles that the repo owner is a member of
295
-
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
296
-
if err != nil {
297
-
l.Error("failed to fetch spindles", "err", err)
298
-
return
299
-
}
300
-
301
-
var secrets []*tangled.RepoListSecrets_Secret
302
-
if f.Spindle != "" {
303
-
if spindleClient, err := rp.oauth.ServiceClient(
304
-
r,
305
-
oauth.WithService(f.Spindle),
306
-
oauth.WithLxm(tangled.RepoListSecretsNSID),
307
-
oauth.WithExp(60),
308
-
oauth.WithDev(rp.config.Core.Dev),
309
-
); err != nil {
310
-
l.Error("failed to create spindle client", "err", err)
311
-
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
312
-
l.Error("failed to fetch secrets", "err", err)
313
-
} else {
314
-
secrets = resp.Secrets
315
-
}
316
-
}
317
-
318
-
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
319
-
return strings.Compare(a.Key, b.Key)
320
-
})
321
-
322
-
var dids []string
323
-
for _, s := range secrets {
324
-
dids = append(dids, s.CreatedBy)
325
-
}
326
-
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
327
-
328
-
// convert to a more manageable form
329
-
var niceSecret []map[string]any
330
-
for id, s := range secrets {
331
-
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
332
-
niceSecret = append(niceSecret, map[string]any{
333
-
"Id": id,
334
-
"Key": s.Key,
335
-
"CreatedAt": when,
336
-
"CreatedBy": resolvedIdents[id].Handle.String(),
337
-
})
338
-
}
339
-
340
-
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
341
-
LoggedInUser: user,
342
-
RepoInfo: f.RepoInfo(user),
343
-
Tabs: settingsTabs,
344
-
Tab: "pipelines",
345
-
Spindles: spindles,
346
-
CurrentSpindle: f.Spindle,
347
-
Secrets: niceSecret,
348
-
})
349
-
}
350
-
351
-
func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) {
352
-
l := rp.logger.With("handler", "EditBaseSettings")
353
-
354
-
noticeId := "repo-base-settings-error"
355
-
356
-
f, err := rp.repoResolver.Resolve(r)
357
-
if err != nil {
358
-
l.Error("failed to get repo and knot", "err", err)
359
-
w.WriteHeader(http.StatusBadRequest)
360
-
return
361
-
}
362
-
363
-
client, err := rp.oauth.AuthorizedClient(r)
364
-
if err != nil {
365
-
l.Error("failed to get client")
366
-
rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.")
367
-
return
368
-
}
369
-
370
-
var (
371
-
description = r.FormValue("description")
372
-
website = r.FormValue("website")
373
-
topicStr = r.FormValue("topics")
374
-
)
375
-
376
-
err = rp.validator.ValidateURI(website)
377
-
if website != "" && err != nil {
378
-
l.Error("invalid uri", "err", err)
379
-
rp.pages.Notice(w, noticeId, err.Error())
380
-
return
381
-
}
382
-
383
-
topics, err := rp.validator.ValidateRepoTopicStr(topicStr)
384
-
if err != nil {
385
-
l.Error("invalid topics", "err", err)
386
-
rp.pages.Notice(w, noticeId, err.Error())
387
-
return
388
-
}
389
-
l.Debug("got", "topicsStr", topicStr, "topics", topics)
390
-
391
-
newRepo := f.Repo
392
-
newRepo.Description = description
393
-
newRepo.Website = website
394
-
newRepo.Topics = topics
395
-
record := newRepo.AsRecord()
396
-
397
-
tx, err := rp.db.BeginTx(r.Context(), nil)
398
-
if err != nil {
399
-
l.Error("failed to begin transaction", "err", err)
400
-
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
401
-
return
402
-
}
403
-
defer tx.Rollback()
404
-
405
-
err = db.PutRepo(tx, newRepo)
406
-
if err != nil {
407
-
l.Error("failed to update repository", "err", err)
408
-
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
409
-
return
410
-
}
411
-
412
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
413
-
if err != nil {
414
-
// failed to get record
415
-
l.Error("failed to get repo record", "err", err)
416
-
rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.")
417
-
return
418
-
}
419
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
420
-
Collection: tangled.RepoNSID,
421
-
Repo: newRepo.Did,
422
-
Rkey: newRepo.Rkey,
423
-
SwapRecord: ex.Cid,
424
-
Record: &lexutil.LexiconTypeDecoder{
425
-
Val: &record,
426
-
},
427
-
})
428
-
429
-
if err != nil {
430
-
l.Error("failed to perferom update-repo query", "err", err)
431
-
// failed to get record
432
-
rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.")
433
-
return
434
-
}
435
-
436
-
err = tx.Commit()
437
-
if err != nil {
438
-
l.Error("failed to commit", "err", err)
439
-
}
440
-
441
-
rp.pages.HxRefresh(w)
442
-
}
-106
appview/repo/tree.go
-106
appview/repo/tree.go
···
1
-
package repo
2
-
3
-
import (
4
-
"fmt"
5
-
"net/http"
6
-
"net/url"
7
-
"strings"
8
-
"time"
9
-
10
-
"tangled.org/core/api/tangled"
11
-
"tangled.org/core/appview/pages"
12
-
xrpcclient "tangled.org/core/appview/xrpcclient"
13
-
"tangled.org/core/types"
14
-
15
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
16
-
"github.com/go-chi/chi/v5"
17
-
"github.com/go-git/go-git/v5/plumbing"
18
-
)
19
-
20
-
func (rp *Repo) Tree(w http.ResponseWriter, r *http.Request) {
21
-
l := rp.logger.With("handler", "RepoTree")
22
-
f, err := rp.repoResolver.Resolve(r)
23
-
if err != nil {
24
-
l.Error("failed to fully resolve repo", "err", err)
25
-
return
26
-
}
27
-
ref := chi.URLParam(r, "ref")
28
-
ref, _ = url.PathUnescape(ref)
29
-
// if the tree path has a trailing slash, let's strip it
30
-
// so we don't 404
31
-
treePath := chi.URLParam(r, "*")
32
-
treePath, _ = url.PathUnescape(treePath)
33
-
treePath = strings.TrimSuffix(treePath, "/")
34
-
scheme := "http"
35
-
if !rp.config.Core.Dev {
36
-
scheme = "https"
37
-
}
38
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
39
-
xrpcc := &indigoxrpc.Client{
40
-
Host: host,
41
-
}
42
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
43
-
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
44
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
45
-
l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
46
-
rp.pages.Error503(w)
47
-
return
48
-
}
49
-
// Convert XRPC response to internal types.RepoTreeResponse
50
-
files := make([]types.NiceTree, len(xrpcResp.Files))
51
-
for i, xrpcFile := range xrpcResp.Files {
52
-
file := types.NiceTree{
53
-
Name: xrpcFile.Name,
54
-
Mode: xrpcFile.Mode,
55
-
Size: int64(xrpcFile.Size),
56
-
}
57
-
// Convert last commit info if present
58
-
if xrpcFile.Last_commit != nil {
59
-
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
60
-
file.LastCommit = &types.LastCommitInfo{
61
-
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
62
-
Message: xrpcFile.Last_commit.Message,
63
-
When: commitWhen,
64
-
}
65
-
}
66
-
files[i] = file
67
-
}
68
-
result := types.RepoTreeResponse{
69
-
Ref: xrpcResp.Ref,
70
-
Files: files,
71
-
}
72
-
if xrpcResp.Parent != nil {
73
-
result.Parent = *xrpcResp.Parent
74
-
}
75
-
if xrpcResp.Dotdot != nil {
76
-
result.DotDot = *xrpcResp.Dotdot
77
-
}
78
-
if xrpcResp.Readme != nil {
79
-
result.ReadmeFileName = xrpcResp.Readme.Filename
80
-
result.Readme = xrpcResp.Readme.Contents
81
-
}
82
-
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
83
-
// so we can safely redirect to the "parent" (which is the same file).
84
-
if len(result.Files) == 0 && result.Parent == treePath {
85
-
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
86
-
http.Redirect(w, r, redirectTo, http.StatusFound)
87
-
return
88
-
}
89
-
user := rp.oauth.GetUser(r)
90
-
var breadcrumbs [][]string
91
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
92
-
if treePath != "" {
93
-
for idx, elem := range strings.Split(treePath, "/") {
94
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
95
-
}
96
-
}
97
-
sortFiles(result.Files)
98
-
99
-
rp.pages.RepoTree(w, pages.RepoTreeParams{
100
-
LoggedInUser: user,
101
-
BreadCrumbs: breadcrumbs,
102
-
TreePath: treePath,
103
-
RepoInfo: f.RepoInfo(user),
104
-
RepoTreeResponse: result,
105
-
})
106
-
}
-2
appview/reporesolver/resolver.go
-2
appview/reporesolver/resolver.go
+4
-6
appview/settings/settings.go
+4
-6
appview/settings/settings.go
···
22
22
"tangled.org/core/tid"
23
23
24
24
comatproto "github.com/bluesky-social/indigo/api/atproto"
25
-
"github.com/bluesky-social/indigo/atproto/syntax"
26
25
lexutil "github.com/bluesky-social/indigo/lex/util"
27
26
"github.com/gliderlabs/ssh"
28
27
"github.com/google/uuid"
···
92
91
user := s.OAuth.GetUser(r)
93
92
did := s.OAuth.GetDid(r)
94
93
95
-
prefs, err := db.GetNotificationPreference(s.Db, did)
94
+
prefs, err := s.Db.GetNotificationPreferences(r.Context(), did)
96
95
if err != nil {
97
96
log.Printf("failed to get notification preferences: %s", err)
98
97
s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.")
···
111
110
did := s.OAuth.GetDid(r)
112
111
113
112
prefs := &models.NotificationPreferences{
114
-
UserDid: syntax.DID(did),
113
+
UserDid: did,
115
114
RepoStarred: r.FormValue("repo_starred") == "on",
116
115
IssueCreated: r.FormValue("issue_created") == "on",
117
116
IssueCommented: r.FormValue("issue_commented") == "on",
···
120
119
PullCommented: r.FormValue("pull_commented") == "on",
121
120
PullMerged: r.FormValue("pull_merged") == "on",
122
121
Followed: r.FormValue("followed") == "on",
123
-
UserMentioned: r.FormValue("user_mentioned") == "on",
124
122
EmailNotifications: r.FormValue("email_notifications") == "on",
125
123
}
126
124
···
472
470
}
473
471
474
472
// store in pds too
475
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
473
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
476
474
Collection: tangled.PublicKeyNSID,
477
475
Repo: did,
478
476
Rkey: rkey,
···
529
527
530
528
if rkey != "" {
531
529
// remove from pds too
532
-
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
530
+
_, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
533
531
Collection: tangled.PublicKeyNSID,
534
532
Repo: did,
535
533
Rkey: rkey,
-18
appview/signup/requests.go
-18
appview/signup/requests.go
···
102
102
103
103
return result.DID, nil
104
104
}
105
-
106
-
func (s *Signup) deleteAccountRequest(did string) error {
107
-
body := map[string]string{
108
-
"did": did,
109
-
}
110
-
111
-
resp, err := s.makePdsRequest("POST", "com.atproto.admin.deleteAccount", body, true)
112
-
if err != nil {
113
-
return err
114
-
}
115
-
defer resp.Body.Close()
116
-
117
-
if resp.StatusCode != http.StatusOK {
118
-
return s.handlePdsError(resp, "delete account")
119
-
}
120
-
121
-
return nil
122
-
}
+40
-95
appview/signup/signup.go
+40
-95
appview/signup/signup.go
···
2
2
3
3
import (
4
4
"bufio"
5
-
"context"
6
5
"encoding/json"
7
6
"errors"
8
7
"fmt"
···
21
20
"tangled.org/core/appview/models"
22
21
"tangled.org/core/appview/pages"
23
22
"tangled.org/core/appview/state/userutil"
23
+
"tangled.org/core/appview/xrpcclient"
24
24
"tangled.org/core/idresolver"
25
25
)
26
26
···
29
29
db *db.DB
30
30
cf *dns.Cloudflare
31
31
posthog posthog.Client
32
+
xrpc *xrpcclient.Client
32
33
idResolver *idresolver.Resolver
33
34
pages *pages.Pages
34
35
l *slog.Logger
···
63
64
disallowed := make(map[string]bool)
64
65
65
66
if filepath == "" {
66
-
logger.Warn("no disallowed nicknames file configured")
67
+
logger.Debug("no disallowed nicknames file configured")
67
68
return disallowed
68
69
}
69
70
···
132
133
noticeId := "signup-msg"
133
134
134
135
if err := s.validateCaptcha(cfToken, r); err != nil {
135
-
s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
136
+
s.l.Warn("turnstile validation failed", "error", err)
136
137
s.pages.Notice(w, noticeId, "Captcha validation failed.")
137
138
return
138
139
}
···
217
218
return
218
219
}
219
220
221
+
did, err := s.createAccountRequest(username, password, email, code)
222
+
if err != nil {
223
+
s.l.Error("failed to create account", "error", err)
224
+
s.pages.Notice(w, "signup-error", err.Error())
225
+
return
226
+
}
227
+
220
228
if s.cf == nil {
221
229
s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
222
230
s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
223
231
return
224
232
}
225
233
226
-
// Execute signup transactionally with rollback capability
227
-
err = s.executeSignupTransaction(r.Context(), username, password, email, code, w)
234
+
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
235
+
Type: "TXT",
236
+
Name: "_atproto." + username,
237
+
Content: fmt.Sprintf(`"did=%s"`, did),
238
+
TTL: 6400,
239
+
Proxied: false,
240
+
})
228
241
if err != nil {
229
-
// Error already logged and notice already sent
242
+
s.l.Error("failed to create DNS record", "error", err)
243
+
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
230
244
return
231
245
}
232
-
}
233
-
}
234
246
235
-
// executeSignupTransaction performs the signup process transactionally with rollback
236
-
func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error {
237
-
var recordID string
238
-
var did string
239
-
var emailAdded bool
247
+
err = db.AddEmail(s.db, models.Email{
248
+
Did: did,
249
+
Address: email,
250
+
Verified: true,
251
+
Primary: true,
252
+
})
253
+
if err != nil {
254
+
s.l.Error("failed to add email", "error", err)
255
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
256
+
return
257
+
}
240
258
241
-
success := false
242
-
defer func() {
243
-
if !success {
244
-
s.l.Info("rolling back signup transaction", "username", username, "did", did)
259
+
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
260
+
<a class="underline text-black dark:text-white" href="/login">login</a>
261
+
with <code>%s.tngl.sh</code>.`, username))
245
262
246
-
// Rollback DNS record
247
-
if recordID != "" {
248
-
if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil {
249
-
s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID)
250
-
} else {
251
-
s.l.Info("successfully rolled back DNS record", "recordID", recordID)
252
-
}
263
+
go func() {
264
+
err := db.DeleteInflightSignup(s.db, email)
265
+
if err != nil {
266
+
s.l.Error("failed to delete inflight signup", "error", err)
253
267
}
254
-
255
-
// Rollback PDS account
256
-
if did != "" {
257
-
if err := s.deleteAccountRequest(did); err != nil {
258
-
s.l.Error("failed to rollback PDS account", "error", err, "did", did)
259
-
} else {
260
-
s.l.Info("successfully rolled back PDS account", "did", did)
261
-
}
262
-
}
263
-
264
-
// Rollback email from database
265
-
if emailAdded {
266
-
if err := db.DeleteEmail(s.db, did, email); err != nil {
267
-
s.l.Error("failed to rollback email from database", "error", err, "email", email)
268
-
} else {
269
-
s.l.Info("successfully rolled back email from database", "email", email)
270
-
}
271
-
}
272
-
}
273
-
}()
274
-
275
-
// step 1: create account in PDS
276
-
did, err := s.createAccountRequest(username, password, email, code)
277
-
if err != nil {
278
-
s.l.Error("failed to create account", "error", err)
279
-
s.pages.Notice(w, "signup-error", err.Error())
280
-
return err
268
+
}()
269
+
return
281
270
}
282
-
283
-
// step 2: create DNS record with actual DID
284
-
recordID, err = s.cf.CreateDNSRecord(ctx, dns.Record{
285
-
Type: "TXT",
286
-
Name: "_atproto." + username,
287
-
Content: fmt.Sprintf(`"did=%s"`, did),
288
-
TTL: 6400,
289
-
Proxied: false,
290
-
})
291
-
if err != nil {
292
-
s.l.Error("failed to create DNS record", "error", err)
293
-
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
294
-
return err
295
-
}
296
-
297
-
// step 3: add email to database
298
-
err = db.AddEmail(s.db, models.Email{
299
-
Did: did,
300
-
Address: email,
301
-
Verified: true,
302
-
Primary: true,
303
-
})
304
-
if err != nil {
305
-
s.l.Error("failed to add email", "error", err)
306
-
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
307
-
return err
308
-
}
309
-
emailAdded = true
310
-
311
-
// if we get here, we've successfully created the account and added the email
312
-
success = true
313
-
314
-
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
315
-
<a class="underline text-black dark:text-white" href="/login">login</a>
316
-
with <code>%s.tngl.sh</code>.`, username))
317
-
318
-
// clean up inflight signup asynchronously
319
-
go func() {
320
-
if err := db.DeleteInflightSignup(s.db, email); err != nil {
321
-
s.l.Error("failed to delete inflight signup", "error", err)
322
-
}
323
-
}()
324
-
325
-
return nil
326
271
}
327
272
328
273
type turnstileResponse struct {
+5
-14
appview/spindles/spindles.go
+5
-14
appview/spindles/spindles.go
···
6
6
"log/slog"
7
7
"net/http"
8
8
"slices"
9
-
"strings"
10
9
"time"
11
10
12
11
"github.com/go-chi/chi/v5"
···
147
146
}
148
147
149
148
instance := r.FormValue("instance")
150
-
// Strip protocol, trailing slashes, and whitespace
151
-
// Rkey cannot contain slashes
152
-
instance = strings.TrimSpace(instance)
153
-
instance = strings.TrimPrefix(instance, "https://")
154
-
instance = strings.TrimPrefix(instance, "http://")
155
-
instance = strings.TrimSuffix(instance, "/")
156
149
if instance == "" {
157
150
s.Pages.Notice(w, noticeId, "Incomplete form.")
158
151
return
···
196
189
return
197
190
}
198
191
199
-
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance)
192
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance)
200
193
var exCid *string
201
194
if ex != nil {
202
195
exCid = ex.Cid
203
196
}
204
197
205
198
// re-announce by registering under same rkey
206
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
199
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
207
200
Collection: tangled.SpindleNSID,
208
201
Repo: user.Did,
209
202
Rkey: instance,
···
339
332
return
340
333
}
341
334
342
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
335
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
343
336
Collection: tangled.SpindleNSID,
344
337
Repo: user.Did,
345
338
Rkey: instance,
···
491
484
}
492
485
493
486
member := r.FormValue("member")
494
-
member = strings.TrimPrefix(member, "@")
495
487
if member == "" {
496
488
l.Error("empty member")
497
489
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
550
542
return
551
543
}
552
544
553
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
545
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
554
546
Collection: tangled.SpindleMemberNSID,
555
547
Repo: user.Did,
556
548
Rkey: rkey,
···
621
613
}
622
614
623
615
member := r.FormValue("member")
624
-
member = strings.TrimPrefix(member, "@")
625
616
if member == "" {
626
617
l.Error("empty member")
627
618
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
···
692
683
}
693
684
694
685
// remove from pds
695
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
686
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
696
687
Collection: tangled.SpindleMemberNSID,
697
688
Repo: user.Did,
698
689
Rkey: members[0].Rkey,
+2
-3
appview/state/follow.go
+2
-3
appview/state/follow.go
···
26
26
subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject)
27
27
if err != nil {
28
28
log.Println("failed to follow, invalid did")
29
-
return
30
29
}
31
30
32
31
if currentUser.Did == subjectIdent.DID.String() {
···
44
43
case http.MethodPost:
45
44
createdAt := time.Now().Format(time.RFC3339)
46
45
rkey := tid.TID()
47
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
46
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
48
47
Collection: tangled.GraphFollowNSID,
49
48
Repo: currentUser.Did,
50
49
Rkey: rkey,
···
89
88
return
90
89
}
91
90
92
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
91
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
93
92
Collection: tangled.GraphFollowNSID,
94
93
Repo: currentUser.Did,
95
94
Rkey: follow.Rkey,
-154
appview/state/gfi.go
-154
appview/state/gfi.go
···
1
-
package state
2
-
3
-
import (
4
-
"log"
5
-
"net/http"
6
-
"sort"
7
-
8
-
"github.com/bluesky-social/indigo/atproto/syntax"
9
-
"tangled.org/core/appview/db"
10
-
"tangled.org/core/appview/models"
11
-
"tangled.org/core/appview/pages"
12
-
"tangled.org/core/appview/pagination"
13
-
"tangled.org/core/consts"
14
-
)
15
-
16
-
func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) {
17
-
user := s.oauth.GetUser(r)
18
-
19
-
page := pagination.FromContext(r.Context())
20
-
21
-
goodFirstIssueLabel := s.config.Label.GoodFirstIssue
22
-
23
-
gfiLabelDef, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", goodFirstIssueLabel))
24
-
if err != nil {
25
-
log.Println("failed to get gfi label def", err)
26
-
s.pages.Error500(w)
27
-
return
28
-
}
29
-
30
-
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
31
-
if err != nil {
32
-
log.Println("failed to get repo labels", err)
33
-
s.pages.Error503(w)
34
-
return
35
-
}
36
-
37
-
if len(repoLabels) == 0 {
38
-
s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{
39
-
LoggedInUser: user,
40
-
RepoGroups: []*models.RepoGroup{},
41
-
LabelDefs: make(map[string]*models.LabelDefinition),
42
-
Page: page,
43
-
GfiLabel: gfiLabelDef,
44
-
})
45
-
return
46
-
}
47
-
48
-
repoUris := make([]string, 0, len(repoLabels))
49
-
for _, rl := range repoLabels {
50
-
repoUris = append(repoUris, rl.RepoAt.String())
51
-
}
52
-
53
-
allIssues, err := db.GetIssuesPaginated(
54
-
s.db,
55
-
pagination.Page{
56
-
Limit: 500,
57
-
},
58
-
db.FilterIn("repo_at", repoUris),
59
-
db.FilterEq("open", 1),
60
-
)
61
-
if err != nil {
62
-
log.Println("failed to get issues", err)
63
-
s.pages.Error503(w)
64
-
return
65
-
}
66
-
67
-
var goodFirstIssues []models.Issue
68
-
for _, issue := range allIssues {
69
-
if issue.Labels.ContainsLabel(goodFirstIssueLabel) {
70
-
goodFirstIssues = append(goodFirstIssues, issue)
71
-
}
72
-
}
73
-
74
-
repoGroups := make(map[syntax.ATURI]*models.RepoGroup)
75
-
for _, issue := range goodFirstIssues {
76
-
if group, exists := repoGroups[issue.Repo.RepoAt()]; exists {
77
-
group.Issues = append(group.Issues, issue)
78
-
} else {
79
-
repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{
80
-
Repo: issue.Repo,
81
-
Issues: []models.Issue{issue},
82
-
}
83
-
}
84
-
}
85
-
86
-
var sortedGroups []*models.RepoGroup
87
-
for _, group := range repoGroups {
88
-
sortedGroups = append(sortedGroups, group)
89
-
}
90
-
91
-
sort.Slice(sortedGroups, func(i, j int) bool {
92
-
iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid
93
-
jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid
94
-
95
-
// If one is tangled and the other isn't, non-tangled comes first
96
-
if iIsTangled != jIsTangled {
97
-
return jIsTangled // true if j is tangled (i should come first)
98
-
}
99
-
100
-
// Both tangled or both not tangled: sort by name
101
-
return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name
102
-
})
103
-
104
-
groupStart := page.Offset
105
-
groupEnd := page.Offset + page.Limit
106
-
if groupStart > len(sortedGroups) {
107
-
groupStart = len(sortedGroups)
108
-
}
109
-
if groupEnd > len(sortedGroups) {
110
-
groupEnd = len(sortedGroups)
111
-
}
112
-
113
-
paginatedGroups := sortedGroups[groupStart:groupEnd]
114
-
115
-
var allIssuesFromGroups []models.Issue
116
-
for _, group := range paginatedGroups {
117
-
allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...)
118
-
}
119
-
120
-
var allLabelDefs []models.LabelDefinition
121
-
if len(allIssuesFromGroups) > 0 {
122
-
labelDefUris := make(map[string]bool)
123
-
for _, issue := range allIssuesFromGroups {
124
-
for labelDefUri := range issue.Labels.Inner() {
125
-
labelDefUris[labelDefUri] = true
126
-
}
127
-
}
128
-
129
-
uriList := make([]string, 0, len(labelDefUris))
130
-
for uri := range labelDefUris {
131
-
uriList = append(uriList, uri)
132
-
}
133
-
134
-
if len(uriList) > 0 {
135
-
allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList))
136
-
if err != nil {
137
-
log.Println("failed to fetch labels", err)
138
-
}
139
-
}
140
-
}
141
-
142
-
labelDefsMap := make(map[string]*models.LabelDefinition)
143
-
for i := range allLabelDefs {
144
-
labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i]
145
-
}
146
-
147
-
s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{
148
-
LoggedInUser: user,
149
-
RepoGroups: paginatedGroups,
150
-
LabelDefs: labelDefsMap,
151
-
Page: page,
152
-
GfiLabel: gfiLabelDef,
153
-
})
154
-
}
+2
-17
appview/state/knotstream.go
+2
-17
appview/state/knotstream.go
···
25
25
)
26
26
27
27
func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) {
28
-
logger := log.FromContext(ctx)
29
-
logger = log.SubLogger(logger, "knotstream")
30
-
31
28
knots, err := db.GetRegistrations(
32
29
d,
33
30
db.FilterIsNot("registered", "null"),
···
42
39
srcs[s] = struct{}{}
43
40
}
44
41
42
+
logger := log.New("knotstream")
45
43
cache := cache.New(c.Redis.Addr)
46
44
cursorStore := cursor.NewRedisCursorStore(cache)
47
45
···
174
172
})
175
173
}
176
174
177
-
tx, err := d.Begin()
178
-
if err != nil {
179
-
return err
180
-
}
181
-
defer tx.Rollback()
182
-
183
-
// update appview's cache
184
-
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs)
185
-
if err != nil {
186
-
fmt.Printf("failed; %s\n", err)
187
-
// non-fatal
188
-
}
189
-
190
-
return tx.Commit()
175
+
return db.InsertRepoLanguages(d, langs)
191
176
}
192
177
193
178
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
-69
appview/state/login.go
-69
appview/state/login.go
···
1
-
package state
2
-
3
-
import (
4
-
"fmt"
5
-
"net/http"
6
-
"strings"
7
-
8
-
"tangled.org/core/appview/pages"
9
-
)
10
-
11
-
func (s *State) Login(w http.ResponseWriter, r *http.Request) {
12
-
l := s.logger.With("handler", "Login")
13
-
14
-
switch r.Method {
15
-
case http.MethodGet:
16
-
returnURL := r.URL.Query().Get("return_url")
17
-
errorCode := r.URL.Query().Get("error")
18
-
s.pages.Login(w, pages.LoginParams{
19
-
ReturnUrl: returnURL,
20
-
ErrorCode: errorCode,
21
-
})
22
-
case http.MethodPost:
23
-
handle := r.FormValue("handle")
24
-
25
-
// when users copy their handle from bsky.app, it tends to have these characters around it:
26
-
//
27
-
// @nelind.dk:
28
-
// \u202a ensures that the handle is always rendered left to right and
29
-
// \u202c reverts that so the rest of the page renders however it should
30
-
handle = strings.TrimPrefix(handle, "\u202a")
31
-
handle = strings.TrimSuffix(handle, "\u202c")
32
-
33
-
// `@` is harmless
34
-
handle = strings.TrimPrefix(handle, "@")
35
-
36
-
// basic handle validation
37
-
if !strings.Contains(handle, ".") {
38
-
l.Error("invalid handle format", "raw", handle)
39
-
s.pages.Notice(
40
-
w,
41
-
"login-msg",
42
-
fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle),
43
-
)
44
-
return
45
-
}
46
-
47
-
redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle)
48
-
if err != nil {
49
-
l.Error("failed to start auth", "err", err)
50
-
http.Error(w, err.Error(), http.StatusInternalServerError)
51
-
return
52
-
}
53
-
54
-
s.pages.HxRedirect(w, redirectURL)
55
-
}
56
-
}
57
-
58
-
func (s *State) Logout(w http.ResponseWriter, r *http.Request) {
59
-
l := s.logger.With("handler", "Logout")
60
-
61
-
err := s.oauth.DeleteSession(w, r)
62
-
if err != nil {
63
-
l.Error("failed to logout", "err", err)
64
-
} else {
65
-
l.Info("logged out successfully")
66
-
}
67
-
68
-
s.pages.HxRedirect(w, "/login")
69
-
}
+2
-4
appview/state/profile.go
+2
-4
appview/state/profile.go
···
538
538
profile.Description = r.FormValue("description")
539
539
profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
540
540
profile.Location = r.FormValue("location")
541
-
profile.Pronouns = r.FormValue("pronouns")
542
541
543
542
var links [5]string
544
543
for i := range 5 {
···
635
634
vanityStats = append(vanityStats, string(v.Kind))
636
635
}
637
636
638
-
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
637
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self")
639
638
var cid *string
640
639
if ex != nil {
641
640
cid = ex.Cid
642
641
}
643
642
644
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
643
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
645
644
Collection: tangled.ActorProfileNSID,
646
645
Repo: user.Did,
647
646
Rkey: "self",
···
653
652
Location: &profile.Location,
654
653
PinnedRepositories: pinnedRepoStrings,
655
654
Stats: vanityStats[:],
656
-
Pronouns: &profile.Pronouns,
657
655
}},
658
656
SwapRecord: cid,
659
657
})
+9
-11
appview/state/reaction.go
+9
-11
appview/state/reaction.go
···
7
7
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
lexutil "github.com/bluesky-social/indigo/lex/util"
11
10
11
+
lexutil "github.com/bluesky-social/indigo/lex/util"
12
12
"tangled.org/core/api/tangled"
13
13
"tangled.org/core/appview/db"
14
14
"tangled.org/core/appview/models"
···
47
47
case http.MethodPost:
48
48
createdAt := time.Now().Format(time.RFC3339)
49
49
rkey := tid.TID()
50
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
50
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
51
51
Collection: tangled.FeedReactionNSID,
52
52
Repo: currentUser.Did,
53
53
Rkey: rkey,
···
70
70
return
71
71
}
72
72
73
-
reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri)
73
+
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
74
74
if err != nil {
75
-
log.Println("failed to get reactions for ", subjectUri)
75
+
log.Println("failed to get reaction count for ", subjectUri)
76
76
}
77
77
78
78
log.Println("created atproto record: ", resp.Uri)
···
80
80
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
81
81
ThreadAt: subjectUri,
82
82
Kind: reactionKind,
83
-
Count: reactionMap[reactionKind].Count,
84
-
Users: reactionMap[reactionKind].Users,
83
+
Count: count,
85
84
IsReacted: true,
86
85
})
87
86
···
93
92
return
94
93
}
95
94
96
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
95
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
97
96
Collection: tangled.FeedReactionNSID,
98
97
Repo: currentUser.Did,
99
98
Rkey: reaction.Rkey,
···
110
109
// this is not an issue, the firehose event might have already done this
111
110
}
112
111
113
-
reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri)
112
+
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
114
113
if err != nil {
115
-
log.Println("failed to get reactions for ", subjectUri)
114
+
log.Println("failed to get reaction count for ", subjectUri)
116
115
return
117
116
}
118
117
119
118
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
120
119
ThreadAt: subjectUri,
121
120
Kind: reactionKind,
122
-
Count: reactionMap[reactionKind].Count,
123
-
Users: reactionMap[reactionKind].Users,
121
+
Count: count,
124
122
IsReacted: false,
125
123
})
126
124
+59
-112
appview/state/router.go
+59
-112
appview/state/router.go
···
5
5
"strings"
6
6
7
7
"github.com/go-chi/chi/v5"
8
+
"github.com/gorilla/sessions"
8
9
"tangled.org/core/appview/issues"
9
10
"tangled.org/core/appview/knots"
10
11
"tangled.org/core/appview/labels"
11
12
"tangled.org/core/appview/middleware"
12
13
"tangled.org/core/appview/notifications"
14
+
oauthhandler "tangled.org/core/appview/oauth/handler"
13
15
"tangled.org/core/appview/pipelines"
14
16
"tangled.org/core/appview/pulls"
15
17
"tangled.org/core/appview/repo"
···
32
34
s.pages,
33
35
)
34
36
37
+
router.Use(middleware.TryRefreshSession())
35
38
router.Get("/favicon.svg", s.Favicon)
36
39
router.Get("/favicon.ico", s.Favicon)
37
-
router.Get("/pwa-manifest.json", s.PWAManifest)
38
-
router.Get("/robots.txt", s.RobotsTxt)
39
40
40
41
userRouter := s.UserRouter(&middleware)
41
42
standardRouter := s.StandardRouter(&middleware)
42
43
43
44
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
44
45
pat := chi.URLParam(r, "*")
45
-
pathParts := strings.SplitN(pat, "/", 2)
46
-
47
-
if len(pathParts) > 0 {
48
-
firstPart := pathParts[0]
49
-
50
-
// if using a DID or handle, just continue as per usual
51
-
if userutil.IsDid(firstPart) || userutil.IsHandle(firstPart) {
52
-
userRouter.ServeHTTP(w, r)
53
-
return
54
-
}
55
-
56
-
// if using a flattened DID (like you would in go modules), unflatten
57
-
if userutil.IsFlattenedDid(firstPart) {
58
-
unflattenedDid := userutil.UnflattenDid(firstPart)
59
-
redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/")
60
-
61
-
redirectURL := *r.URL
62
-
redirectURL.Path = "/" + redirectPath
63
-
64
-
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
65
-
return
66
-
}
67
-
68
-
// if using a handle with @, rewrite to work without @
69
-
if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) {
70
-
redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/")
71
-
72
-
redirectURL := *r.URL
73
-
redirectURL.Path = "/" + redirectPath
74
-
75
-
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
76
-
return
46
+
if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
47
+
userRouter.ServeHTTP(w, r)
48
+
} else {
49
+
// Check if the first path element is a valid handle without '@' or a flattened DID
50
+
pathParts := strings.SplitN(pat, "/", 2)
51
+
if len(pathParts) > 0 {
52
+
if userutil.IsHandleNoAt(pathParts[0]) {
53
+
// Redirect to the same path but with '@' prefixed to the handle
54
+
redirectPath := "@" + pat
55
+
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
56
+
return
57
+
} else if userutil.IsFlattenedDid(pathParts[0]) {
58
+
// Redirect to the unflattened DID version
59
+
unflattenedDid := userutil.UnflattenDid(pathParts[0])
60
+
var redirectPath string
61
+
if len(pathParts) > 1 {
62
+
redirectPath = unflattenedDid + "/" + pathParts[1]
63
+
} else {
64
+
redirectPath = unflattenedDid
65
+
}
66
+
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
67
+
return
68
+
}
77
69
}
78
-
70
+
standardRouter.ServeHTTP(w, r)
79
71
}
80
-
81
-
standardRouter.ServeHTTP(w, r)
82
72
})
83
73
84
74
return router
···
91
81
r.Get("/", s.Profile)
92
82
r.Get("/feed.atom", s.AtomFeedPage)
93
83
84
+
// redirect /@handle/repo.git -> /@handle/repo
85
+
r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) {
86
+
nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git")
87
+
http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently)
88
+
})
89
+
94
90
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
95
91
r.Use(mw.GoImport())
96
92
r.Mount("/", s.RepoRouter(mw))
97
93
r.Mount("/issues", s.IssuesRouter(mw))
98
94
r.Mount("/pulls", s.PullsRouter(mw))
99
-
r.Mount("/pipelines", s.PipelinesRouter())
100
-
r.Mount("/labels", s.LabelsRouter())
95
+
r.Mount("/pipelines", s.PipelinesRouter(mw))
96
+
r.Mount("/labels", s.LabelsRouter(mw))
101
97
102
98
// These routes get proxied to the knot
103
99
r.Get("/info/refs", s.InfoRefs)
···
126
122
// special-case handler for serving tangled.org/core
127
123
r.Get("/core", s.Core())
128
124
129
-
r.Get("/login", s.Login)
130
-
r.Post("/login", s.Login)
131
-
r.Post("/logout", s.Logout)
132
-
133
125
r.Route("/repo", func(r chi.Router) {
134
126
r.Route("/new", func(r chi.Router) {
135
127
r.Use(middleware.AuthMiddleware(s.oauth))
···
138
130
})
139
131
// r.Post("/import", s.ImportRepo)
140
132
})
141
-
142
-
r.Get("/goodfirstissues", s.GoodFirstIssues)
143
133
144
134
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
145
135
r.Post("/", s.Follow)
···
171
161
r.Mount("/notifications", s.NotificationsRouter(mw))
172
162
173
163
r.Mount("/signup", s.SignupRouter())
174
-
r.Mount("/", s.oauth.Router())
164
+
r.Mount("/", s.OAuthRouter())
175
165
176
166
r.Get("/keys/{user}", s.Keys)
177
167
r.Get("/terms", s.TermsOfService)
···
198
188
}
199
189
}
200
190
191
+
func (s *State) OAuthRouter() http.Handler {
192
+
store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret))
193
+
oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog)
194
+
return oauth.Router()
195
+
}
196
+
201
197
func (s *State) SettingsRouter() http.Handler {
202
198
settings := &settings.Settings{
203
199
Db: s.db,
···
210
206
}
211
207
212
208
func (s *State) SpindlesRouter() http.Handler {
213
-
logger := log.SubLogger(s.logger, "spindles")
209
+
logger := log.New("spindles")
214
210
215
211
spindles := &spindles.Spindles{
216
212
Db: s.db,
···
226
222
}
227
223
228
224
func (s *State) KnotsRouter() http.Handler {
229
-
logger := log.SubLogger(s.logger, "knots")
225
+
logger := log.New("knots")
230
226
231
227
knots := &knots.Knots{
232
228
Db: s.db,
···
243
239
}
244
240
245
241
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
246
-
logger := log.SubLogger(s.logger, "strings")
242
+
logger := log.New("strings")
247
243
248
244
strs := &avstrings.Strings{
249
245
Db: s.db,
···
258
254
}
259
255
260
256
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
261
-
issues := issues.New(
262
-
s.oauth,
263
-
s.repoResolver,
264
-
s.pages,
265
-
s.idResolver,
266
-
s.db,
267
-
s.config,
268
-
s.notifier,
269
-
s.validator,
270
-
s.indexer.Issues,
271
-
log.SubLogger(s.logger, "issues"),
272
-
)
257
+
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator)
273
258
return issues.Router(mw)
274
259
}
275
260
276
261
func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {
277
-
pulls := pulls.New(
278
-
s.oauth,
279
-
s.repoResolver,
280
-
s.pages,
281
-
s.idResolver,
282
-
s.db,
283
-
s.config,
284
-
s.notifier,
285
-
s.enforcer,
286
-
s.validator,
287
-
s.indexer.Pulls,
288
-
log.SubLogger(s.logger, "pulls"),
289
-
)
262
+
pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
290
263
return pulls.Router(mw)
291
264
}
292
265
293
266
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
294
-
repo := repo.New(
295
-
s.oauth,
296
-
s.repoResolver,
297
-
s.pages,
298
-
s.spindlestream,
299
-
s.idResolver,
300
-
s.db,
301
-
s.config,
302
-
s.notifier,
303
-
s.enforcer,
304
-
log.SubLogger(s.logger, "repo"),
305
-
s.validator,
306
-
)
267
+
logger := log.New("repo")
268
+
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger, s.validator)
307
269
return repo.Router(mw)
308
270
}
309
271
310
-
func (s *State) PipelinesRouter() http.Handler {
311
-
pipes := pipelines.New(
312
-
s.oauth,
313
-
s.repoResolver,
314
-
s.pages,
315
-
s.spindlestream,
316
-
s.idResolver,
317
-
s.db,
318
-
s.config,
319
-
s.enforcer,
320
-
log.SubLogger(s.logger, "pipelines"),
321
-
)
322
-
return pipes.Router()
272
+
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
273
+
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
274
+
return pipes.Router(mw)
323
275
}
324
276
325
-
func (s *State) LabelsRouter() http.Handler {
326
-
ls := labels.New(
327
-
s.oauth,
328
-
s.pages,
329
-
s.db,
330
-
s.validator,
331
-
s.enforcer,
332
-
log.SubLogger(s.logger, "labels"),
333
-
)
334
-
return ls.Router()
277
+
func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler {
278
+
ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer)
279
+
return ls.Router(mw)
335
280
}
336
281
337
282
func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
338
-
notifs := notifications.New(s.db, s.oauth, s.pages, log.SubLogger(s.logger, "notifications"))
283
+
notifs := notifications.New(s.db, s.oauth, s.pages)
339
284
return notifs.Router(mw)
340
285
}
341
286
342
287
func (s *State) SignupRouter() http.Handler {
343
-
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup"))
288
+
logger := log.New("signup")
289
+
290
+
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger)
344
291
return sig.Router()
345
292
}
+1
-3
appview/state/spindlestream.go
+1
-3
appview/state/spindlestream.go
···
22
22
)
23
23
24
24
func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) {
25
-
logger := log.FromContext(ctx)
26
-
logger = log.SubLogger(logger, "spindlestream")
27
-
28
25
spindles, err := db.GetSpindles(
29
26
d,
30
27
db.FilterIsNot("verified", "null"),
···
39
36
srcs[src] = struct{}{}
40
37
}
41
38
39
+
logger := log.New("spindlestream")
42
40
cache := cache.New(c.Redis.Addr)
43
41
cursorStore := cursor.NewRedisCursorStore(cache)
44
42
+2
-2
appview/state/star.go
+2
-2
appview/state/star.go
···
40
40
case http.MethodPost:
41
41
createdAt := time.Now().Format(time.RFC3339)
42
42
rkey := tid.TID()
43
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
43
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
44
44
Collection: tangled.FeedStarNSID,
45
45
Repo: currentUser.Did,
46
46
Rkey: rkey,
···
92
92
return
93
93
}
94
94
95
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
95
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
96
96
Collection: tangled.FeedStarNSID,
97
97
Repo: currentUser.Did,
98
98
Rkey: star.Rkey,
+47
-101
appview/state/state.go
+47
-101
appview/state/state.go
···
5
5
"database/sql"
6
6
"errors"
7
7
"fmt"
8
+
"log"
8
9
"log/slog"
9
10
"net/http"
10
11
"strings"
11
12
"time"
12
13
14
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
+
"github.com/bluesky-social/indigo/atproto/syntax"
16
+
lexutil "github.com/bluesky-social/indigo/lex/util"
17
+
securejoin "github.com/cyphar/filepath-securejoin"
18
+
"github.com/go-chi/chi/v5"
19
+
"github.com/posthog/posthog-go"
13
20
"tangled.org/core/api/tangled"
14
21
"tangled.org/core/appview"
22
+
"tangled.org/core/appview/cache"
23
+
"tangled.org/core/appview/cache/session"
15
24
"tangled.org/core/appview/config"
16
25
"tangled.org/core/appview/db"
17
-
"tangled.org/core/appview/indexer"
18
26
"tangled.org/core/appview/models"
19
27
"tangled.org/core/appview/notify"
20
28
dbnotify "tangled.org/core/appview/notify/db"
···
27
35
"tangled.org/core/eventconsumer"
28
36
"tangled.org/core/idresolver"
29
37
"tangled.org/core/jetstream"
30
-
"tangled.org/core/log"
31
38
tlog "tangled.org/core/log"
32
39
"tangled.org/core/rbac"
33
40
"tangled.org/core/tid"
34
-
35
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
36
-
atpclient "github.com/bluesky-social/indigo/atproto/client"
37
-
"github.com/bluesky-social/indigo/atproto/syntax"
38
-
lexutil "github.com/bluesky-social/indigo/lex/util"
39
-
securejoin "github.com/cyphar/filepath-securejoin"
40
-
"github.com/go-chi/chi/v5"
41
-
"github.com/posthog/posthog-go"
42
41
)
43
42
44
43
type State struct {
45
44
db *db.DB
46
45
notifier notify.Notifier
47
-
indexer *indexer.Indexer
48
46
oauth *oauth.OAuth
49
47
enforcer *rbac.Enforcer
50
48
pages *pages.Pages
49
+
sess *session.SessionStore
51
50
idResolver *idresolver.Resolver
52
51
posthog posthog.Client
53
52
jc *jetstream.JetstreamClient
···
60
59
}
61
60
62
61
func Make(ctx context.Context, config *config.Config) (*State, error) {
63
-
logger := tlog.FromContext(ctx)
64
-
65
-
d, err := db.Make(ctx, config.Core.DbPath)
62
+
d, err := db.Make(config.Core.DbPath)
66
63
if err != nil {
67
64
return nil, fmt.Errorf("failed to create db: %w", err)
68
65
}
69
66
70
-
indexer := indexer.New(log.SubLogger(logger, "indexer"))
71
-
err = indexer.Init(ctx, d)
72
-
if err != nil {
73
-
return nil, fmt.Errorf("failed to create indexer: %w", err)
74
-
}
75
-
76
67
enforcer, err := rbac.NewEnforcer(config.Core.DbPath)
77
68
if err != nil {
78
69
return nil, fmt.Errorf("failed to create enforcer: %w", err)
79
70
}
80
71
81
-
res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL)
72
+
res, err := idresolver.RedisResolver(config.Redis.ToURL())
82
73
if err != nil {
83
-
logger.Error("failed to create redis resolver", "err", err)
84
-
res = idresolver.DefaultResolver(config.Plc.PLCURL)
74
+
log.Printf("failed to create redis resolver: %v", err)
75
+
res = idresolver.DefaultResolver()
85
76
}
86
77
78
+
pgs := pages.NewPages(config, res)
79
+
cache := cache.New(config.Redis.Addr)
80
+
sess := session.New(cache)
81
+
oauth := oauth.NewOAuth(config, sess)
82
+
validator := validator.New(d, res, enforcer)
83
+
87
84
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
88
85
if err != nil {
89
86
return nil, fmt.Errorf("failed to create posthog client: %w", err)
90
87
}
91
88
92
-
pages := pages.NewPages(config, res, log.SubLogger(logger, "pages"))
93
-
oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth"))
94
-
if err != nil {
95
-
return nil, fmt.Errorf("failed to start oauth handler: %w", err)
96
-
}
97
-
validator := validator.New(d, res, enforcer)
98
-
99
89
repoResolver := reporesolver.New(config, enforcer, res, d)
100
90
101
91
wrapper := db.DbWrapper{Execer: d}
···
117
107
tangled.LabelOpNSID,
118
108
},
119
109
nil,
120
-
tlog.SubLogger(logger, "jetstream"),
110
+
slog.Default(),
121
111
wrapper,
122
112
false,
123
113
···
129
119
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
130
120
}
131
121
132
-
if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil {
122
+
if err := BackfillDefaultDefs(d, res); err != nil {
133
123
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
134
124
}
135
125
···
138
128
Enforcer: enforcer,
139
129
IdResolver: res,
140
130
Config: config,
141
-
Logger: log.SubLogger(logger, "ingester"),
131
+
Logger: tlog.New("ingester"),
142
132
Validator: validator,
143
133
}
144
134
err = jc.StartJetstream(ctx, ingester.Ingest())
···
167
157
if !config.Core.Dev {
168
158
notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog))
169
159
}
170
-
notifiers = append(notifiers, indexer)
171
-
notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify"))
160
+
notifier := notify.NewMergedNotifier(notifiers...)
172
161
173
162
state := &State{
174
163
d,
175
164
notifier,
176
-
indexer,
177
165
oauth,
178
166
enforcer,
179
-
pages,
167
+
pgs,
168
+
sess,
180
169
res,
181
170
posthog,
182
171
jc,
···
184
173
repoResolver,
185
174
knotstream,
186
175
spindlestream,
187
-
logger,
176
+
slog.Default(),
188
177
validator,
189
178
}
190
179
···
209
198
s.pages.Favicon(w)
210
199
}
211
200
212
-
func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) {
213
-
w.Header().Set("Content-Type", "text/plain")
214
-
w.Header().Set("Cache-Control", "public, max-age=86400") // one day
215
-
216
-
robotsTxt := `User-agent: *
217
-
Allow: /
218
-
`
219
-
w.Write([]byte(robotsTxt))
220
-
}
221
-
222
-
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
223
-
const manifestJson = `{
224
-
"name": "tangled",
225
-
"description": "tightly-knit social coding.",
226
-
"icons": [
227
-
{
228
-
"src": "/favicon.svg",
229
-
"sizes": "144x144"
230
-
}
231
-
],
232
-
"start_url": "/",
233
-
"id": "org.tangled",
234
-
235
-
"display": "standalone",
236
-
"background_color": "#111827",
237
-
"theme_color": "#111827"
238
-
}`
239
-
240
-
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
241
-
w.Header().Set("Content-Type", "application/json")
242
-
w.Write([]byte(manifestJson))
243
-
}
244
-
245
201
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
246
202
user := s.oauth.GetUser(r)
247
203
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
274
230
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
275
231
user := s.oauth.GetUser(r)
276
232
277
-
// TODO: set this flag based on the UI
278
-
filtered := false
279
-
280
233
var userDid string
281
234
if user != nil {
282
235
userDid = user.Did
283
236
}
284
-
timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered)
237
+
timeline, err := db.MakeTimeline(s.db, 50, userDid)
285
238
if err != nil {
286
-
s.logger.Error("failed to make timeline", "err", err)
239
+
log.Println(err)
287
240
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
288
241
}
289
242
290
243
repos, err := db.GetTopStarredReposLastWeek(s.db)
291
244
if err != nil {
292
-
s.logger.Error("failed to get top starred repos", "err", err)
245
+
log.Println(err)
293
246
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
294
247
return
295
248
}
296
249
297
-
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
298
-
if err != nil {
299
-
// non-fatal
300
-
}
301
-
302
250
s.pages.Timeline(w, pages.TimelineParams{
303
251
LoggedInUser: user,
304
252
Timeline: timeline,
305
253
Repos: repos,
306
-
GfiLabel: gfiLabel,
307
254
})
308
255
}
309
256
···
315
262
316
263
l := s.logger.With("handler", "UpgradeBanner")
317
264
l = l.With("did", user.Did)
265
+
l = l.With("handle", user.Handle)
318
266
319
267
regs, err := db.GetRegistrations(
320
268
s.db,
···
345
293
}
346
294
347
295
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
348
-
// TODO: set this flag based on the UI
349
-
filtered := false
350
-
351
-
timeline, err := db.MakeTimeline(s.db, 5, "", filtered)
296
+
timeline, err := db.MakeTimeline(s.db, 5, "")
352
297
if err != nil {
353
-
s.logger.Error("failed to make timeline", "err", err)
298
+
log.Println(err)
354
299
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
355
300
return
356
301
}
357
302
358
303
repos, err := db.GetTopStarredReposLastWeek(s.db)
359
304
if err != nil {
360
-
s.logger.Error("failed to get top starred repos", "err", err)
305
+
log.Println(err)
361
306
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
362
307
return
363
308
}
···
386
331
387
332
pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String())
388
333
if err != nil {
389
-
s.logger.Error("failed to get public keys", "err", err)
390
-
http.Error(w, "failed to get public keys", http.StatusInternalServerError)
334
+
w.WriteHeader(http.StatusNotFound)
391
335
return
392
336
}
393
337
394
338
if len(pubKeys) == 0 {
395
-
w.WriteHeader(http.StatusNoContent)
339
+
w.WriteHeader(http.StatusNotFound)
396
340
return
397
341
}
398
342
···
458
402
459
403
user := s.oauth.GetUser(r)
460
404
l = l.With("did", user.Did)
405
+
l = l.With("handle", user.Handle)
461
406
462
407
// form validation
463
408
domain := r.FormValue("domain")
···
517
462
Rkey: rkey,
518
463
Description: description,
519
464
Created: time.Now(),
520
-
Labels: s.config.Label.DefaultLabelDefs,
465
+
Labels: models.DefaultLabelDefs(),
521
466
}
522
467
record := repo.AsRecord()
523
468
524
-
atpClient, err := s.oauth.AuthorizedClient(r)
469
+
xrpcClient, err := s.oauth.AuthorizedClient(r)
525
470
if err != nil {
526
471
l.Info("PDS write failed", "err", err)
527
472
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
528
473
return
529
474
}
530
475
531
-
atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
476
+
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
532
477
Collection: tangled.RepoNSID,
533
478
Repo: user.Did,
534
479
Rkey: rkey,
···
560
505
rollback := func() {
561
506
err1 := tx.Rollback()
562
507
err2 := s.enforcer.E.LoadPolicy()
563
-
err3 := rollbackRecord(context.Background(), aturi, atpClient)
508
+
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
564
509
565
510
// ignore txn complete errors, this is okay
566
511
if errors.Is(err1, sql.ErrTxDone) {
···
633
578
aturi = ""
634
579
635
580
s.notifier.NewRepo(r.Context(), repo)
636
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName))
581
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
637
582
}
638
583
}
639
584
640
585
// this is used to rollback changes made to the PDS
641
586
//
642
587
// it is a no-op if the provided ATURI is empty
643
-
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
588
+
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
644
589
if aturi == "" {
645
590
return nil
646
591
}
···
651
596
repo := parsed.Authority().String()
652
597
rkey := parsed.RecordKey().String()
653
598
654
-
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
599
+
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
655
600
Collection: collection,
656
601
Repo: repo,
657
602
Rkey: rkey,
···
659
604
return err
660
605
}
661
606
662
-
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
607
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error {
608
+
defaults := models.DefaultLabelDefs()
663
609
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
664
610
if err != nil {
665
611
return err
···
669
615
return nil
670
616
}
671
617
672
-
labelDefs, err := models.FetchLabelDefs(r, defaults)
618
+
labelDefs, err := models.FetchDefaultDefs(r)
673
619
if err != nil {
674
620
return err
675
621
}
+6
-6
appview/state/userutil/userutil.go
+6
-6
appview/state/userutil/userutil.go
···
10
10
didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`)
11
11
)
12
12
13
-
func IsHandle(s string) bool {
13
+
func IsHandleNoAt(s string) bool {
14
14
// ref: https://atproto.com/specs/handle
15
15
return handleRegex.MatchString(s)
16
-
}
17
-
18
-
// IsDid checks if the given string is a standard DID.
19
-
func IsDid(s string) bool {
20
-
return didRegex.MatchString(s)
21
16
}
22
17
23
18
func UnflattenDid(s string) string {
···
50
45
return strings.Replace(s, ":", "-", 2)
51
46
}
52
47
return s
48
+
}
49
+
50
+
// IsDid checks if the given string is a standard DID.
51
+
func IsDid(s string) bool {
52
+
return didRegex.MatchString(s)
53
53
}
54
54
55
55
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
+7
-9
appview/strings/strings.go
+7
-9
appview/strings/strings.go
···
22
22
"github.com/bluesky-social/indigo/api/atproto"
23
23
"github.com/bluesky-social/indigo/atproto/identity"
24
24
"github.com/bluesky-social/indigo/atproto/syntax"
25
-
"github.com/go-chi/chi/v5"
26
-
27
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
28
25
lexutil "github.com/bluesky-social/indigo/lex/util"
26
+
"github.com/go-chi/chi/v5"
29
27
)
30
28
31
29
type Strings struct {
···
256
254
}
257
255
258
256
// first replace the existing record in the PDS
259
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
257
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
260
258
if err != nil {
261
259
fail("Failed to updated existing record.", err)
262
260
return
263
261
}
264
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
262
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
265
263
Collection: tangled.StringNSID,
266
264
Repo: entry.Did.String(),
267
265
Rkey: entry.Rkey,
···
286
284
s.Notifier.EditString(r.Context(), &entry)
287
285
288
286
// if that went okay, redir to the string
289
-
s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey)
287
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
290
288
}
291
289
292
290
}
···
338
336
return
339
337
}
340
338
341
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
339
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
342
340
Collection: tangled.StringNSID,
343
341
Repo: user.Did,
344
342
Rkey: string.Rkey,
···
362
360
s.Notifier.NewString(r.Context(), &string)
363
361
364
362
// successful
365
-
s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey)
363
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
366
364
}
367
365
}
368
366
···
405
403
406
404
s.Notifier.DeleteString(r.Context(), user.Did, rkey)
407
405
408
-
s.Pages.HxRedirect(w, "/strings/"+user.Did)
406
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
409
407
}
410
408
411
409
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
-25
appview/validator/patch.go
-25
appview/validator/patch.go
···
1
-
package validator
2
-
3
-
import (
4
-
"fmt"
5
-
"strings"
6
-
7
-
"tangled.org/core/patchutil"
8
-
)
9
-
10
-
func (v *Validator) ValidatePatch(patch *string) error {
11
-
if patch == nil || *patch == "" {
12
-
return fmt.Errorf("patch is empty")
13
-
}
14
-
15
-
// add newline if not present to diff style patches
16
-
if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") {
17
-
*patch = *patch + "\n"
18
-
}
19
-
20
-
if err := patchutil.IsPatchValid(*patch); err != nil {
21
-
return err
22
-
}
23
-
24
-
return nil
25
-
}
-53
appview/validator/repo_topics.go
-53
appview/validator/repo_topics.go
···
1
-
package validator
2
-
3
-
import (
4
-
"fmt"
5
-
"maps"
6
-
"regexp"
7
-
"slices"
8
-
"strings"
9
-
)
10
-
11
-
const (
12
-
maxTopicLen = 50
13
-
maxTopics = 20
14
-
)
15
-
16
-
var (
17
-
topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`)
18
-
)
19
-
20
-
// ValidateRepoTopicStr parses and validates whitespace-separated topic string.
21
-
//
22
-
// Rules:
23
-
// - topics are separated by whitespace
24
-
// - each topic may contain lowercase letters, digits, and hyphens only
25
-
// - each topic must be <= 50 characters long
26
-
// - no more than 20 topics allowed
27
-
// - duplicates are removed
28
-
func (v *Validator) ValidateRepoTopicStr(topicsStr string) ([]string, error) {
29
-
topicsStr = strings.TrimSpace(topicsStr)
30
-
if topicsStr == "" {
31
-
return nil, nil
32
-
}
33
-
parts := strings.Fields(topicsStr)
34
-
if len(parts) > maxTopics {
35
-
return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics)
36
-
}
37
-
38
-
topicSet := make(map[string]struct{})
39
-
40
-
for _, t := range parts {
41
-
if _, exists := topicSet[t]; exists {
42
-
continue
43
-
}
44
-
if len(t) > maxTopicLen {
45
-
return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics)
46
-
}
47
-
if !topicRE.MatchString(t) {
48
-
return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t)
49
-
}
50
-
topicSet[t] = struct{}{}
51
-
}
52
-
return slices.Collect(maps.Keys(topicSet)), nil
53
-
}
-17
appview/validator/uri.go
-17
appview/validator/uri.go
···
1
-
package validator
2
-
3
-
import (
4
-
"fmt"
5
-
"net/url"
6
-
)
7
-
8
-
func (v *Validator) ValidateURI(uri string) error {
9
-
parsed, err := url.Parse(uri)
10
-
if err != nil {
11
-
return fmt.Errorf("invalid uri format")
12
-
}
13
-
if parsed.Scheme == "" {
14
-
return fmt.Errorf("uri scheme missing")
15
-
}
16
-
return nil
17
-
}
+99
appview/xrpcclient/xrpc.go
+99
appview/xrpcclient/xrpc.go
···
1
1
package xrpcclient
2
2
3
3
import (
4
+
"bytes"
5
+
"context"
4
6
"errors"
7
+
"io"
5
8
"net/http"
6
9
10
+
"github.com/bluesky-social/indigo/api/atproto"
11
+
"github.com/bluesky-social/indigo/xrpc"
7
12
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
13
+
oauth "tangled.sh/icyphox.sh/atproto-oauth"
8
14
)
9
15
10
16
var (
···
13
19
ErrXrpcFailed = errors.New("xrpc request failed")
14
20
ErrXrpcInvalid = errors.New("invalid xrpc request")
15
21
)
22
+
23
+
type Client struct {
24
+
*oauth.XrpcClient
25
+
authArgs *oauth.XrpcAuthedRequestArgs
26
+
}
27
+
28
+
func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client {
29
+
return &Client{
30
+
XrpcClient: client,
31
+
authArgs: authArgs,
32
+
}
33
+
}
34
+
35
+
func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) {
36
+
var out atproto.RepoPutRecord_Output
37
+
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil {
38
+
return nil, err
39
+
}
40
+
41
+
return &out, nil
42
+
}
43
+
44
+
func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) {
45
+
var out atproto.RepoApplyWrites_Output
46
+
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil {
47
+
return nil, err
48
+
}
49
+
50
+
return &out, nil
51
+
}
52
+
53
+
func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) {
54
+
var out atproto.RepoGetRecord_Output
55
+
56
+
params := map[string]interface{}{
57
+
"cid": cid,
58
+
"collection": collection,
59
+
"repo": repo,
60
+
"rkey": rkey,
61
+
}
62
+
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil {
63
+
return nil, err
64
+
}
65
+
66
+
return &out, nil
67
+
}
68
+
69
+
func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) {
70
+
var out atproto.RepoUploadBlob_Output
71
+
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil {
72
+
return nil, err
73
+
}
74
+
75
+
return &out, nil
76
+
}
77
+
78
+
func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) {
79
+
buf := new(bytes.Buffer)
80
+
81
+
params := map[string]interface{}{
82
+
"cid": cid,
83
+
"did": did,
84
+
}
85
+
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil {
86
+
return nil, err
87
+
}
88
+
89
+
return buf.Bytes(), nil
90
+
}
91
+
92
+
func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) {
93
+
var out atproto.RepoDeleteRecord_Output
94
+
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil {
95
+
return nil, err
96
+
}
97
+
98
+
return &out, nil
99
+
}
100
+
101
+
func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) {
102
+
var out atproto.ServerGetServiceAuth_Output
103
+
104
+
params := map[string]interface{}{
105
+
"aud": aud,
106
+
"exp": exp,
107
+
"lxm": lxm,
108
+
}
109
+
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil {
110
+
return nil, err
111
+
}
112
+
113
+
return &out, nil
114
+
}
16
115
17
116
// produces a more manageable error
18
117
func HandleXrpcErr(err error) error {
+9
-14
cmd/appview/main.go
+9
-14
cmd/appview/main.go
···
2
2
3
3
import (
4
4
"context"
5
+
"log"
6
+
"log/slog"
5
7
"net/http"
6
8
"os"
7
9
8
10
"tangled.org/core/appview/config"
9
11
"tangled.org/core/appview/state"
10
-
tlog "tangled.org/core/log"
11
12
)
12
13
13
14
func main() {
15
+
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil)))
16
+
14
17
ctx := context.Background()
15
-
logger := tlog.New("appview")
16
-
ctx = tlog.IntoContext(ctx, logger)
17
18
18
19
c, err := config.LoadConfig(ctx)
19
20
if err != nil {
20
-
logger.Error("failed to load config", "error", err)
21
+
log.Println("failed to load config", "error", err)
21
22
return
22
23
}
23
24
24
25
state, err := state.Make(ctx, c)
25
26
defer func() {
26
-
if err := state.Close(); err != nil {
27
-
logger.Error("failed to close state", "err", err)
28
-
}
27
+
log.Println(state.Close())
29
28
}()
30
29
31
30
if err != nil {
32
-
logger.Error("failed to start appview", "err", err)
33
-
os.Exit(-1)
31
+
log.Fatal(err)
34
32
}
35
33
36
-
logger.Info("starting server", "address", c.Core.ListenAddr)
37
-
38
-
if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil {
39
-
logger.Error("failed to start appview", "err", err)
40
-
}
34
+
log.Println("starting server on", c.Core.ListenAddr)
35
+
log.Println(http.ListenAndServe(c.Core.ListenAddr, state.Router()))
41
36
}
-62
cmd/cborgen/cborgen.go
-62
cmd/cborgen/cborgen.go
···
1
-
package main
2
-
3
-
import (
4
-
cbg "github.com/whyrusleeping/cbor-gen"
5
-
"tangled.org/core/api/tangled"
6
-
)
7
-
8
-
func main() {
9
-
10
-
genCfg := cbg.Gen{
11
-
MaxStringLength: 1_000_000,
12
-
}
13
-
14
-
if err := genCfg.WriteMapEncodersToFile(
15
-
"api/tangled/cbor_gen.go",
16
-
"tangled",
17
-
tangled.ActorProfile{},
18
-
tangled.FeedReaction{},
19
-
tangled.FeedStar{},
20
-
tangled.GitRefUpdate{},
21
-
tangled.GitRefUpdate_CommitCountBreakdown{},
22
-
tangled.GitRefUpdate_IndividualEmailCommitCount{},
23
-
tangled.GitRefUpdate_IndividualLanguageSize{},
24
-
tangled.GitRefUpdate_LangBreakdown{},
25
-
tangled.GitRefUpdate_Meta{},
26
-
tangled.GraphFollow{},
27
-
tangled.Knot{},
28
-
tangled.KnotMember{},
29
-
tangled.LabelDefinition{},
30
-
tangled.LabelDefinition_ValueType{},
31
-
tangled.LabelOp{},
32
-
tangled.LabelOp_Operand{},
33
-
tangled.Pipeline{},
34
-
tangled.Pipeline_CloneOpts{},
35
-
tangled.Pipeline_ManualTriggerData{},
36
-
tangled.Pipeline_Pair{},
37
-
tangled.Pipeline_PullRequestTriggerData{},
38
-
tangled.Pipeline_PushTriggerData{},
39
-
tangled.PipelineStatus{},
40
-
tangled.Pipeline_TriggerMetadata{},
41
-
tangled.Pipeline_TriggerRepo{},
42
-
tangled.Pipeline_Workflow{},
43
-
tangled.PublicKey{},
44
-
tangled.Repo{},
45
-
tangled.RepoArtifact{},
46
-
tangled.RepoCollaborator{},
47
-
tangled.RepoIssue{},
48
-
tangled.RepoIssueComment{},
49
-
tangled.RepoIssueState{},
50
-
tangled.RepoPull{},
51
-
tangled.RepoPullComment{},
52
-
tangled.RepoPull_Source{},
53
-
tangled.RepoPullStatus{},
54
-
tangled.RepoPull_Target{},
55
-
tangled.Spindle{},
56
-
tangled.SpindleMember{},
57
-
tangled.String{},
58
-
); err != nil {
59
-
panic(err)
60
-
}
61
-
62
-
}
+62
cmd/gen.go
+62
cmd/gen.go
···
1
+
package main
2
+
3
+
import (
4
+
cbg "github.com/whyrusleeping/cbor-gen"
5
+
"tangled.org/core/api/tangled"
6
+
)
7
+
8
+
func main() {
9
+
10
+
genCfg := cbg.Gen{
11
+
MaxStringLength: 1_000_000,
12
+
}
13
+
14
+
if err := genCfg.WriteMapEncodersToFile(
15
+
"api/tangled/cbor_gen.go",
16
+
"tangled",
17
+
tangled.ActorProfile{},
18
+
tangled.FeedReaction{},
19
+
tangled.FeedStar{},
20
+
tangled.GitRefUpdate{},
21
+
tangled.GitRefUpdate_CommitCountBreakdown{},
22
+
tangled.GitRefUpdate_IndividualEmailCommitCount{},
23
+
tangled.GitRefUpdate_IndividualLanguageSize{},
24
+
tangled.GitRefUpdate_LangBreakdown{},
25
+
tangled.GitRefUpdate_Meta{},
26
+
tangled.GraphFollow{},
27
+
tangled.Knot{},
28
+
tangled.KnotMember{},
29
+
tangled.LabelDefinition{},
30
+
tangled.LabelDefinition_ValueType{},
31
+
tangled.LabelOp{},
32
+
tangled.LabelOp_Operand{},
33
+
tangled.Pipeline{},
34
+
tangled.Pipeline_CloneOpts{},
35
+
tangled.Pipeline_ManualTriggerData{},
36
+
tangled.Pipeline_Pair{},
37
+
tangled.Pipeline_PullRequestTriggerData{},
38
+
tangled.Pipeline_PushTriggerData{},
39
+
tangled.PipelineStatus{},
40
+
tangled.Pipeline_TriggerMetadata{},
41
+
tangled.Pipeline_TriggerRepo{},
42
+
tangled.Pipeline_Workflow{},
43
+
tangled.PublicKey{},
44
+
tangled.Repo{},
45
+
tangled.RepoArtifact{},
46
+
tangled.RepoCollaborator{},
47
+
tangled.RepoIssue{},
48
+
tangled.RepoIssueComment{},
49
+
tangled.RepoIssueState{},
50
+
tangled.RepoPull{},
51
+
tangled.RepoPullComment{},
52
+
tangled.RepoPull_Source{},
53
+
tangled.RepoPullStatus{},
54
+
tangled.RepoPull_Target{},
55
+
tangled.Spindle{},
56
+
tangled.SpindleMember{},
57
+
tangled.String{},
58
+
); err != nil {
59
+
panic(err)
60
+
}
61
+
62
+
}
+43
cmd/genjwks/main.go
+43
cmd/genjwks/main.go
···
1
+
// adapted from https://tangled.sh/icyphox.sh/atproto-oauth
2
+
3
+
package main
4
+
5
+
import (
6
+
"crypto/ecdsa"
7
+
"crypto/elliptic"
8
+
"crypto/rand"
9
+
"encoding/json"
10
+
"fmt"
11
+
"time"
12
+
13
+
"github.com/lestrrat-go/jwx/v2/jwk"
14
+
)
15
+
16
+
func main() {
17
+
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
18
+
if err != nil {
19
+
panic(err)
20
+
}
21
+
22
+
key, err := jwk.FromRaw(privKey)
23
+
if err != nil {
24
+
panic(err)
25
+
}
26
+
27
+
kid := fmt.Sprintf("%d", time.Now().Unix())
28
+
29
+
if err := key.Set(jwk.KeyIDKey, kid); err != nil {
30
+
panic(err)
31
+
}
32
+
33
+
if err := key.Set("use", "sig"); err != nil {
34
+
panic(err)
35
+
}
36
+
37
+
b, err := json.Marshal(key)
38
+
if err != nil {
39
+
panic(err)
40
+
}
41
+
42
+
fmt.Println(string(b))
43
+
}
+3
-6
cmd/knot/main.go
+3
-6
cmd/knot/main.go
···
2
2
3
3
import (
4
4
"context"
5
-
"log/slog"
6
5
"os"
7
6
8
7
"github.com/urfave/cli/v3"
···
10
9
"tangled.org/core/hook"
11
10
"tangled.org/core/keyfetch"
12
11
"tangled.org/core/knotserver"
13
-
tlog "tangled.org/core/log"
12
+
"tangled.org/core/log"
14
13
)
15
14
16
15
func main() {
···
25
24
},
26
25
}
27
26
28
-
logger := tlog.New("knot")
29
-
slog.SetDefault(logger)
30
-
31
27
ctx := context.Background()
32
-
ctx = tlog.IntoContext(ctx, logger)
28
+
logger := log.New("knot")
29
+
ctx = log.IntoContext(ctx, logger.With("command", cmd.Name))
33
30
34
31
if err := cmd.Run(ctx, os.Args); err != nil {
35
32
logger.Error(err.Error())
+49
cmd/punchcardPopulate/main.go
+49
cmd/punchcardPopulate/main.go
···
1
+
package main
2
+
3
+
import (
4
+
"database/sql"
5
+
"fmt"
6
+
"log"
7
+
"math/rand"
8
+
"time"
9
+
10
+
_ "github.com/mattn/go-sqlite3"
11
+
)
12
+
13
+
func main() {
14
+
db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1")
15
+
if err != nil {
16
+
log.Fatal("Failed to open database:", err)
17
+
}
18
+
defer db.Close()
19
+
20
+
const did = "did:plc:qfpnj4og54vl56wngdriaxug"
21
+
22
+
now := time.Now()
23
+
start := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
24
+
25
+
tx, err := db.Begin()
26
+
if err != nil {
27
+
log.Fatal(err)
28
+
}
29
+
stmt, err := tx.Prepare("INSERT INTO punchcard (did, date, count) VALUES (?, ?, ?)")
30
+
if err != nil {
31
+
log.Fatal(err)
32
+
}
33
+
defer stmt.Close()
34
+
35
+
for day := start; !day.After(now); day = day.AddDate(0, 0, 1) {
36
+
count := rand.Intn(16) // 0–5
37
+
dateStr := day.Format("2006-01-02")
38
+
_, err := stmt.Exec(did, dateStr, count)
39
+
if err != nil {
40
+
log.Printf("Failed to insert for date %s: %v", dateStr, err)
41
+
}
42
+
}
43
+
44
+
if err := tx.Commit(); err != nil {
45
+
log.Fatal("Failed to commit:", err)
46
+
}
47
+
48
+
fmt.Println("Done populating punchcard.")
49
+
}
+4
-9
cmd/spindle/main.go
+4
-9
cmd/spindle/main.go
···
2
2
3
3
import (
4
4
"context"
5
-
"log/slog"
6
5
"os"
7
6
8
-
tlog "tangled.org/core/log"
7
+
"tangled.org/core/log"
9
8
"tangled.org/core/spindle"
9
+
_ "tangled.org/core/tid"
10
10
)
11
11
12
12
func main() {
13
-
logger := tlog.New("spindle")
14
-
slog.SetDefault(logger)
15
-
16
-
ctx := context.Background()
17
-
ctx = tlog.IntoContext(ctx, logger)
18
-
13
+
ctx := log.NewContext(context.Background(), "spindle")
19
14
err := spindle.Run(ctx)
20
15
if err != nil {
21
-
logger.Error("error running spindle", "error", err)
16
+
log.FromContext(ctx).Error("error running spindle", "error", err)
22
17
os.Exit(-1)
23
18
}
24
19
}
+6
-16
docs/hacking.md
+6
-16
docs/hacking.md
···
37
37
38
38
```
39
39
# oauth jwks should already be setup by the nix devshell:
40
-
echo $TANGLED_OAUTH_CLIENT_SECRET
41
-
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
42
-
43
-
echo $TANGLED_OAUTH_CLIENT_KID
44
-
1761667908
40
+
echo $TANGLED_OAUTH_JWKS
41
+
{"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"}
45
42
46
43
# if not, you can set it up yourself:
47
-
goat key generate -t P-256
48
-
Key Type: P-256 / secp256r1 / ES256 private key
49
-
Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
50
-
z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
51
-
Public Key (DID Key Syntax): share or publish this (eg, in DID document)
52
-
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
53
-
54
-
# the secret key from above
55
-
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
44
+
go build -o genjwks.out ./cmd/genjwks
45
+
export TANGLED_OAUTH_JWKS="$(./genjwks.out)"
56
46
57
47
# run redis in at a new shell to store oauth sessions
58
48
redis-server
···
168
158
169
159
If for any reason you wish to disable either one of the
170
160
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
171
-
`services.tangled.spindle.enable` (or
172
-
`services.tangled.knot.enable`) to `false`.
161
+
`services.tangled-spindle.enable` (or
162
+
`services.tangled-knot.enable`) to `false`.
+1
-2
docs/knot-hosting.md
+1
-2
docs/knot-hosting.md
···
39
39
```
40
40
41
41
Next, move the `knot` binary to a location owned by `root` --
42
-
`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
42
+
`/usr/local/bin/knot` is a good choice:
43
43
44
44
```
45
45
sudo mv knot /usr/local/bin/knot
46
-
sudo chown root:root /usr/local/bin/knot
47
46
```
48
47
49
48
This is necessary because SSH `AuthorizedKeysCommand` requires [really
+1
-1
docs/migrations.md
+1
-1
docs/migrations.md
+2
-20
docs/spindle/pipeline.md
+2
-20
docs/spindle/pipeline.md
···
19
19
- `push`: The workflow should run every time a commit is pushed to the repository.
20
20
- `pull_request`: The workflow should run every time a pull request is made or updated.
21
21
- `manual`: The workflow can be triggered manually.
22
-
- `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events.
23
-
- `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events.
22
+
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
24
23
25
-
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
24
+
For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
26
25
27
26
```yaml
28
27
when:
···
30
29
branch: ["main", "develop"]
31
30
- event: ["pull_request"]
32
31
branch: ["main"]
33
-
```
34
-
35
-
You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed:
36
-
37
-
```yaml
38
-
when:
39
-
- event: ["push"]
40
-
tag: ["v*"]
41
-
```
42
-
43
-
You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches):
44
-
45
-
```yaml
46
-
when:
47
-
- event: ["push"]
48
-
branch: ["main", "release-*"]
49
-
tag: ["v*", "stable"]
50
32
```
51
33
52
34
## Engine
-17
flake.lock
-17
flake.lock
···
1
1
{
2
2
"nodes": {
3
-
"actor-typeahead-src": {
4
-
"flake": false,
5
-
"locked": {
6
-
"lastModified": 1762835797,
7
-
"narHash": "sha256-heizoWUKDdar6ymfZTnj3ytcEv/L4d4fzSmtr0HlXsQ=",
8
-
"ref": "refs/heads/main",
9
-
"rev": "677fe7f743050a4e7f09d4a6f87bbf1325a06f6b",
10
-
"revCount": 6,
11
-
"type": "git",
12
-
"url": "https://tangled.org/@jakelazaroff.com/actor-typeahead"
13
-
},
14
-
"original": {
15
-
"type": "git",
16
-
"url": "https://tangled.org/@jakelazaroff.com/actor-typeahead"
17
-
}
18
-
},
19
3
"flake-compat": {
20
4
"flake": false,
21
5
"locked": {
···
166
150
},
167
151
"root": {
168
152
"inputs": {
169
-
"actor-typeahead-src": "actor-typeahead-src",
170
153
"flake-compat": "flake-compat",
171
154
"gomod2nix": "gomod2nix",
172
155
"htmx-src": "htmx-src",
+9
-19
flake.nix
+9
-19
flake.nix
···
33
33
url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip";
34
34
flake = false;
35
35
};
36
-
actor-typeahead-src = {
37
-
url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead";
38
-
flake = false;
39
-
};
40
36
ibm-plex-mono-src = {
41
37
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
42
38
flake = false;
···
58
54
inter-fonts-src,
59
55
sqlite-lib-src,
60
56
ibm-plex-mono-src,
61
-
actor-typeahead-src,
62
57
...
63
58
}: let
64
59
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
···
83
78
inherit (pkgs) gcc;
84
79
inherit sqlite-lib-src;
85
80
};
81
+
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
86
82
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
87
-
goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;};
88
83
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
89
-
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
84
+
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
90
85
};
91
86
appview = self.callPackage ./nix/pkgs/appview.nix {};
92
87
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
···
95
90
});
96
91
in {
97
92
overlays.default = final: prev: {
98
-
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview;
93
+
inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview;
99
94
};
100
95
101
96
packages = forAllSystems (system: let
···
104
99
staticPackages = mkPackageSet pkgs.pkgsStatic;
105
100
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
106
101
in {
107
-
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib;
102
+
inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib;
108
103
109
104
pkgsStatic-appview = staticPackages.appview;
110
105
pkgsStatic-knot = staticPackages.knot;
···
172
167
mkdir -p appview/pages/static
173
168
# no preserve is needed because watch-tailwind will want to be able to overwrite
174
169
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
175
-
export TANGLED_OAUTH_CLIENT_KID="$(date +%s)"
176
-
export TANGLED_OAUTH_CLIENT_SECRET="$(${packages'.goat}/bin/goat key generate -t P-256 | grep -A1 "Secret Key" | tail -n1 | awk '{print $1}')"
170
+
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
177
171
'';
178
172
env.CGO_ENABLED = 1;
179
173
};
···
212
206
watch-knot = {
213
207
type = "app";
214
208
program = ''${air-watcher "knot" "server"}/bin/run'';
215
-
};
216
-
watch-spindle = {
217
-
type = "app";
218
-
program = ''${air-watcher "spindle" ""}/bin/run'';
219
209
};
220
210
watch-tailwind = {
221
211
type = "app";
···
272
262
lexgen --build-file lexicon-build-config.json lexicons
273
263
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
274
264
${pkgs.gotools}/bin/goimports -w api/tangled/*
275
-
go run ./cmd/cborgen/
265
+
go run cmd/gen.go
276
266
lexgen --build-file lexicon-build-config.json lexicons
277
267
rm api/tangled/*.bak
278
268
'';
···
288
278
}: {
289
279
imports = [./nix/modules/appview.nix];
290
280
291
-
services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
281
+
services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
292
282
};
293
283
nixosModules.knot = {
294
284
lib,
···
297
287
}: {
298
288
imports = [./nix/modules/knot.nix];
299
289
300
-
services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
290
+
services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
301
291
};
302
292
nixosModules.spindle = {
303
293
lib,
···
306
296
}: {
307
297
imports = [./nix/modules/spindle.nix];
308
298
309
-
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
299
+
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
310
300
};
311
301
};
312
302
}
+17
-53
go.mod
+17
-53
go.mod
···
7
7
github.com/alecthomas/assert/v2 v2.11.0
8
8
github.com/alecthomas/chroma/v2 v2.15.0
9
9
github.com/avast/retry-go/v4 v4.6.1
10
-
github.com/blevesearch/bleve/v2 v2.5.3
11
10
github.com/bluekeyes/go-gitdiff v0.8.1
12
-
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
11
+
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb
13
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
14
-
github.com/bmatcuk/doublestar/v4 v4.9.1
15
13
github.com/carlmjohnson/versioninfo v0.22.5
16
14
github.com/casbin/casbin/v2 v2.103.0
17
-
github.com/charmbracelet/log v0.4.2
18
15
github.com/cloudflare/cloudflare-go v0.115.0
19
16
github.com/cyphar/filepath-securejoin v0.4.1
20
17
github.com/dgraph-io/ristretto v0.2.0
···
24
21
github.com/go-chi/chi/v5 v5.2.0
25
22
github.com/go-enry/go-enry/v2 v2.9.2
26
23
github.com/go-git/go-git/v5 v5.14.0
27
-
github.com/goki/freetype v1.0.5
28
24
github.com/google/uuid v1.6.0
29
25
github.com/gorilla/feeds v1.2.0
30
26
github.com/gorilla/sessions v1.4.0
···
32
28
github.com/hiddeco/sshsig v0.2.0
33
29
github.com/hpcloud/tail v1.0.0
34
30
github.com/ipfs/go-cid v0.5.0
31
+
github.com/lestrrat-go/jwx/v2 v2.1.6
35
32
github.com/mattn/go-sqlite3 v1.14.24
36
33
github.com/microcosm-cc/bluemonday v1.0.27
37
34
github.com/openbao/openbao/api/v2 v2.3.0
···
39
36
github.com/redis/go-redis/v9 v9.7.3
40
37
github.com/resend/resend-go/v2 v2.15.0
41
38
github.com/sethvargo/go-envconfig v1.1.0
42
-
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
43
-
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
44
39
github.com/stretchr/testify v1.10.0
45
40
github.com/urfave/cli/v3 v3.3.3
46
41
github.com/whyrusleeping/cbor-gen v0.3.1
47
-
github.com/wyatt915/goldmark-treeblood v0.0.1
48
-
github.com/yuin/goldmark v1.7.13
42
+
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57
43
+
github.com/yuin/goldmark v1.7.12
49
44
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
50
-
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
51
45
golang.org/x/crypto v0.40.0
52
-
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
53
-
golang.org/x/image v0.31.0
54
46
golang.org/x/net v0.42.0
55
-
golang.org/x/sync v0.17.0
47
+
golang.org/x/sync v0.16.0
56
48
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
57
49
gopkg.in/yaml.v3 v3.0.1
50
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1
58
51
)
59
52
60
53
require (
61
54
dario.cat/mergo v1.0.1 // indirect
62
55
github.com/Microsoft/go-winio v0.6.2 // indirect
63
56
github.com/ProtonMail/go-crypto v1.3.0 // indirect
64
-
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
65
57
github.com/alecthomas/repr v0.4.0 // indirect
66
58
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
67
-
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
68
59
github.com/aymerick/douceur v0.2.0 // indirect
69
60
github.com/beorn7/perks v1.0.1 // indirect
70
-
github.com/bits-and-blooms/bitset v1.22.0 // indirect
71
-
github.com/blevesearch/bleve_index_api v1.2.8 // indirect
72
-
github.com/blevesearch/geo v0.2.4 // indirect
73
-
github.com/blevesearch/go-faiss v1.0.25 // indirect
74
-
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
75
-
github.com/blevesearch/gtreap v0.1.1 // indirect
76
-
github.com/blevesearch/mmap-go v1.0.4 // indirect
77
-
github.com/blevesearch/scorch_segment_api/v2 v2.3.10 // indirect
78
-
github.com/blevesearch/segment v0.9.1 // indirect
79
-
github.com/blevesearch/snowballstem v0.9.0 // indirect
80
-
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
81
-
github.com/blevesearch/vellum v1.1.0 // indirect
82
-
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
83
-
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
84
-
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
85
-
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
86
-
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
87
-
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
61
+
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
88
62
github.com/casbin/govaluate v1.3.0 // indirect
89
63
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
90
64
github.com/cespare/xxhash/v2 v2.3.0 // indirect
91
-
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
92
-
github.com/charmbracelet/lipgloss v1.1.0 // indirect
93
-
github.com/charmbracelet/x/ansi v0.8.0 // indirect
94
-
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
95
-
github.com/charmbracelet/x/term v0.2.1 // indirect
96
65
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect
97
66
github.com/containerd/errdefs v1.0.0 // indirect
98
67
github.com/containerd/errdefs/pkg v0.3.0 // indirect
99
68
github.com/containerd/log v0.1.0 // indirect
100
69
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
70
+
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
101
71
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
102
72
github.com/distribution/reference v0.6.0 // indirect
103
73
github.com/dlclark/regexp2 v1.11.5 // indirect
···
110
80
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
111
81
github.com/go-git/go-billy/v5 v5.6.2 // indirect
112
82
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
113
-
github.com/go-logfmt/logfmt v0.6.0 // indirect
114
83
github.com/go-logr/logr v1.4.3 // indirect
115
84
github.com/go-logr/stdr v1.2.2 // indirect
116
85
github.com/go-redis/cache/v9 v9.0.0 // indirect
···
120
89
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
121
90
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
122
91
github.com/golang/mock v1.6.0 // indirect
123
-
github.com/golang/protobuf v1.5.4 // indirect
124
-
github.com/golang/snappy v0.0.4 // indirect
125
92
github.com/google/go-querystring v1.1.0 // indirect
126
93
github.com/gorilla/css v1.0.1 // indirect
127
94
github.com/gorilla/securecookie v1.1.2 // indirect
···
147
114
github.com/ipfs/go-log v1.0.5 // indirect
148
115
github.com/ipfs/go-log/v2 v2.6.0 // indirect
149
116
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
150
-
github.com/json-iterator/go v1.1.12 // indirect
151
117
github.com/kevinburke/ssh_config v1.2.0 // indirect
152
118
github.com/klauspost/compress v1.18.0 // indirect
153
119
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
154
-
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
120
+
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
121
+
github.com/lestrrat-go/httpcc v1.0.1 // indirect
122
+
github.com/lestrrat-go/httprc v1.0.6 // indirect
123
+
github.com/lestrrat-go/iter v1.0.2 // indirect
124
+
github.com/lestrrat-go/option v1.0.1 // indirect
155
125
github.com/mattn/go-isatty v0.0.20 // indirect
156
-
github.com/mattn/go-runewidth v0.0.16 // indirect
157
126
github.com/minio/sha256-simd v1.0.1 // indirect
158
127
github.com/mitchellh/mapstructure v1.5.0 // indirect
159
128
github.com/moby/docker-image-spec v1.3.1 // indirect
160
129
github.com/moby/sys/atomicwriter v0.1.0 // indirect
161
130
github.com/moby/term v0.5.2 // indirect
162
-
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
163
-
github.com/modern-go/reflect2 v1.0.2 // indirect
164
131
github.com/morikuni/aec v1.0.0 // indirect
165
132
github.com/mr-tron/base58 v1.2.0 // indirect
166
-
github.com/mschoch/smat v0.2.0 // indirect
167
-
github.com/muesli/termenv v0.16.0 // indirect
168
133
github.com/multiformats/go-base32 v0.1.0 // indirect
169
134
github.com/multiformats/go-base36 v0.2.0 // indirect
170
135
github.com/multiformats/go-multibase v0.2.0 // indirect
···
183
148
github.com/prometheus/client_model v0.6.2 // indirect
184
149
github.com/prometheus/common v0.64.0 // indirect
185
150
github.com/prometheus/procfs v0.16.1 // indirect
186
-
github.com/rivo/uniseg v0.4.7 // indirect
187
151
github.com/ryanuber/go-glob v1.0.0 // indirect
152
+
github.com/segmentio/asm v1.2.0 // indirect
188
153
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
189
154
github.com/spaolacci/murmur3 v1.1.0 // indirect
190
155
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
191
156
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
192
157
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
193
-
github.com/wyatt915/treeblood v0.1.16 // indirect
194
-
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
158
+
github.com/wyatt915/treeblood v0.1.15 // indirect
195
159
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
196
160
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
197
-
go.etcd.io/bbolt v1.4.0 // indirect
198
161
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
199
162
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
200
163
go.opentelemetry.io/otel v1.37.0 // indirect
···
205
168
go.uber.org/atomic v1.11.0 // indirect
206
169
go.uber.org/multierr v1.11.0 // indirect
207
170
go.uber.org/zap v1.27.0 // indirect
171
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
208
172
golang.org/x/sys v0.34.0 // indirect
209
-
golang.org/x/text v0.29.0 // indirect
173
+
golang.org/x/text v0.27.0 // indirect
210
174
golang.org/x/time v0.12.0 // indirect
211
175
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
212
176
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+33
-109
go.sum
+33
-109
go.sum
···
9
9
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
10
10
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
11
11
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
12
-
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
13
-
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
14
12
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
15
13
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
16
14
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
···
21
19
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
22
20
github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
23
21
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
24
-
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
25
-
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
26
22
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
27
23
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
28
24
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
29
25
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
30
-
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
31
-
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
32
-
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
33
-
github.com/blevesearch/bleve/v2 v2.5.3 h1:9l1xtKaETv64SZc1jc4Sy0N804laSa/LeMbYddq1YEM=
34
-
github.com/blevesearch/bleve/v2 v2.5.3/go.mod h1:Z/e8aWjiq8HeX+nW8qROSxiE0830yQA071dwR3yoMzw=
35
-
github.com/blevesearch/bleve_index_api v1.2.8 h1:Y98Pu5/MdlkRyLM0qDHostYo7i+Vv1cDNhqTeR4Sy6Y=
36
-
github.com/blevesearch/bleve_index_api v1.2.8/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
37
-
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
38
-
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
39
-
github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U=
40
-
github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
41
-
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
42
-
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
43
-
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
44
-
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
45
-
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
46
-
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
47
-
github.com/blevesearch/scorch_segment_api/v2 v2.3.10 h1:Yqk0XD1mE0fDZAJXTjawJ8If/85JxnLd8v5vG/jWE/s=
48
-
github.com/blevesearch/scorch_segment_api/v2 v2.3.10/go.mod h1:Z3e6ChN3qyN35yaQpl00MfI5s8AxUJbpTR/DL8QOQ+8=
49
-
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
50
-
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
51
-
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
52
-
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
53
-
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
54
-
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
55
-
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
56
-
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
57
-
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
58
-
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
59
-
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
60
-
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
61
-
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
62
-
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
63
-
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
64
-
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
65
-
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
66
-
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
67
-
github.com/blevesearch/zapx/v16 v16.2.4 h1:tGgfvleXTAkwsD5mEzgM3zCS/7pgocTCnO1oyAUjlww=
68
-
github.com/blevesearch/zapx/v16 v16.2.4/go.mod h1:Rti/REtuuMmzwsI8/C/qIzRaEoSK/wiFYw5e5ctUKKs=
69
-
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU=
70
-
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
26
+
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ=
27
+
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
71
28
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
72
29
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
73
30
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
31
+
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
74
32
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
75
-
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
76
-
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
77
33
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
78
34
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
79
35
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
···
92
48
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
93
49
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
94
50
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
95
-
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
96
-
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
97
-
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
98
-
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
99
-
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
100
-
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
101
-
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
102
-
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
103
-
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
104
-
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
105
-
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
106
-
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
107
51
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
108
52
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
109
53
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
···
125
69
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
126
70
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
127
71
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
72
+
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
73
+
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
128
74
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
129
75
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
130
76
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
···
174
120
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM=
175
121
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
176
122
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
177
-
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
178
-
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
179
123
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
180
124
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
181
125
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
···
192
136
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
193
137
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
194
138
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
195
-
github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A=
196
-
github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E=
197
139
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
198
140
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
199
141
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
···
210
152
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
211
153
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
212
154
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
213
-
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
214
-
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
215
-
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
216
-
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
217
155
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
218
156
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
219
157
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
···
225
163
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
226
164
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
227
165
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
228
-
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
229
166
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
230
167
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
231
168
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
306
243
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
307
244
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
308
245
github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY=
309
-
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
310
-
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
246
+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
247
+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
311
248
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
312
249
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
313
250
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
···
327
264
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
328
265
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
329
266
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
330
-
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
331
-
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
267
+
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
268
+
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
269
+
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
270
+
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
271
+
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
272
+
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
273
+
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
274
+
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
275
+
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
276
+
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
277
+
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
278
+
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
332
279
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
333
280
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
334
281
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
335
282
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
336
-
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
337
-
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
338
283
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
339
284
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
340
285
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
···
351
296
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
352
297
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
353
298
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
354
-
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
355
-
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
356
-
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
357
-
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
358
-
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
359
299
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
360
300
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
361
301
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
362
302
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
363
-
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
364
-
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
365
-
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
366
-
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
367
303
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
368
304
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
369
305
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
···
441
377
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
442
378
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
443
379
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
444
-
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
445
-
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
446
-
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
447
380
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
448
381
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
449
382
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
···
451
384
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
452
385
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
453
386
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
387
+
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
388
+
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
454
389
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
455
390
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
456
391
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
···
464
399
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
465
400
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
466
401
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
467
-
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
468
-
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
469
-
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
470
-
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
471
402
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
472
403
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
473
404
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
···
495
426
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
496
427
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
497
428
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
498
-
github.com/wyatt915/goldmark-treeblood v0.0.1 h1:6vLJcjFrHgE4ASu2ga4hqIQmbvQLU37v53jlHZ3pqDs=
499
-
github.com/wyatt915/goldmark-treeblood v0.0.1/go.mod h1:SmcJp5EBaV17rroNlgNQFydYwy0+fv85CUr/ZaCz208=
500
-
github.com/wyatt915/treeblood v0.1.16 h1:byxNbWZhnPDxdTp7W5kQhCeaY8RBVmojTFz1tEHgg8Y=
501
-
github.com/wyatt915/treeblood v0.1.16/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY=
502
-
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
503
-
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
429
+
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 h1:UqtQdzLXnvdBdqn/go53qGyncw1wJ7Mq5SQdieM1/Ew=
430
+
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs=
431
+
github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8=
432
+
github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY=
504
433
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
505
434
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
506
435
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
507
436
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
508
437
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
509
438
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
510
-
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
511
-
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
439
+
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
440
+
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
512
441
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
513
442
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
514
-
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
515
-
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c=
516
443
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
517
444
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
518
445
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
519
446
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
520
-
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
521
-
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
522
447
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
523
448
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
524
449
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
···
564
489
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
565
490
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
566
491
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
567
-
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
568
-
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
569
492
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
570
493
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
571
494
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
···
605
528
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
606
529
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
607
530
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
608
-
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
609
-
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
531
+
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
532
+
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
610
533
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
611
534
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
612
535
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
660
583
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
661
584
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
662
585
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
663
-
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
664
-
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
586
+
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
587
+
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
665
588
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
666
589
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
667
590
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
···
722
645
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
723
646
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
724
647
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
725
-
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
726
648
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
727
649
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
728
650
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
···
730
652
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
731
653
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
732
654
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
655
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU=
656
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg=
733
657
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
734
658
tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+61
-36
guard/guard.go
+61
-36
guard/guard.go
···
12
12
"os/exec"
13
13
"strings"
14
14
15
+
"github.com/bluesky-social/indigo/atproto/identity"
15
16
securejoin "github.com/cyphar/filepath-securejoin"
16
17
"github.com/urfave/cli/v3"
18
+
"tangled.org/core/idresolver"
17
19
"tangled.org/core/log"
18
20
)
19
21
···
91
93
"command", sshCommand,
92
94
"client", clientIP)
93
95
94
-
// TODO: greet user with their resolved handle instead of did
95
96
if sshCommand == "" {
96
97
l.Info("access denied: no interactive shells", "user", incomingUser)
97
98
fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
···
106
107
}
107
108
108
109
gitCommand := cmdParts[0]
109
-
repoPath := cmdParts[1]
110
+
111
+
// did:foo/repo-name or
112
+
// handle/repo-name or
113
+
// any of the above with a leading slash (/)
114
+
115
+
components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/")
116
+
l.Info("command components", "components", components)
117
+
118
+
if len(components) != 2 {
119
+
l.Error("invalid repo format", "components", components)
120
+
fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
121
+
os.Exit(-1)
122
+
}
123
+
124
+
didOrHandle := components[0]
125
+
identity := resolveIdentity(ctx, l, didOrHandle)
126
+
did := identity.DID.String()
127
+
repoName := components[1]
128
+
qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName)
110
129
111
130
validCommands := map[string]bool{
112
131
"git-receive-pack": true,
···
119
138
return fmt.Errorf("access denied: invalid git command")
120
139
}
121
140
122
-
// qualify repo path from internal server which holds the knot config
123
-
qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand)
124
-
if err != nil {
125
-
l.Error("failed to run guard", "err", err)
126
-
fmt.Fprintln(os.Stderr, err)
127
-
os.Exit(1)
141
+
if gitCommand != "git-upload-pack" {
142
+
if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) {
143
+
l.Error("access denied: user not allowed",
144
+
"did", incomingUser,
145
+
"reponame", qualifiedRepoName)
146
+
fmt.Fprintln(os.Stderr, "access denied: user not allowed")
147
+
os.Exit(-1)
148
+
}
128
149
}
129
150
130
-
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath)
151
+
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName)
131
152
132
153
l.Info("processing command",
133
154
"user", incomingUser,
134
155
"command", gitCommand,
135
-
"repo", repoPath,
156
+
"repo", repoName,
136
157
"fullPath", fullPath,
137
158
"client", clientIP)
138
159
···
156
177
gitCmd.Stdin = os.Stdin
157
178
gitCmd.Env = append(os.Environ(),
158
179
fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
180
+
fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()),
159
181
)
160
182
161
183
if err := gitCmd.Run(); err != nil {
···
167
189
l.Info("command completed",
168
190
"user", incomingUser,
169
191
"command", gitCommand,
170
-
"repo", repoPath,
192
+
"repo", repoName,
171
193
"success", true)
172
194
173
195
return nil
174
196
}
175
197
176
-
// runs guardAndQualifyRepo logic
177
-
func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) {
178
-
u, _ := url.Parse(endpoint + "/guard")
198
+
func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity {
199
+
resolver := idresolver.DefaultResolver()
200
+
ident, err := resolver.ResolveIdent(ctx, didOrHandle)
201
+
if err != nil {
202
+
l.Error("Error resolving handle", "error", err, "handle", didOrHandle)
203
+
fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err)
204
+
os.Exit(1)
205
+
}
206
+
if ident.Handle.IsInvalidHandle() {
207
+
l.Error("Error resolving handle", "invalid handle", didOrHandle)
208
+
fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n")
209
+
os.Exit(1)
210
+
}
211
+
return ident
212
+
}
213
+
214
+
func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool {
215
+
u, _ := url.Parse(endpoint + "/push-allowed")
179
216
q := u.Query()
180
-
q.Add("user", incomingUser)
181
-
q.Add("repo", repo)
182
-
q.Add("gitCmd", gitCommand)
217
+
q.Add("user", user)
218
+
q.Add("repo", qualifiedRepoName)
183
219
u.RawQuery = q.Encode()
184
220
185
-
resp, err := http.Get(u.String())
221
+
req, err := http.Get(u.String())
186
222
if err != nil {
187
-
return "", err
223
+
l.Error("Error verifying permissions", "error", err)
224
+
fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err)
225
+
os.Exit(1)
188
226
}
189
-
defer resp.Body.Close()
190
-
191
-
l.Info("Running guard", "url", u.String(), "status", resp.Status)
192
227
193
-
body, err := io.ReadAll(resp.Body)
194
-
if err != nil {
195
-
return "", err
196
-
}
197
-
text := string(body)
228
+
l.Info("Checking push permission",
229
+
"url", u.String(),
230
+
"status", req.Status)
198
231
199
-
switch resp.StatusCode {
200
-
case http.StatusOK:
201
-
return text, nil
202
-
case http.StatusForbidden:
203
-
l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text)
204
-
return text, errors.New("access denied: user not allowed")
205
-
default:
206
-
return "", errors.New(text)
207
-
}
232
+
return req.StatusCode == http.StatusNoContent
208
233
}
+8
-17
idresolver/resolver.go
+8
-17
idresolver/resolver.go
···
17
17
directory identity.Directory
18
18
}
19
19
20
-
func BaseDirectory(plcUrl string) identity.Directory {
20
+
func BaseDirectory() identity.Directory {
21
21
base := identity.BaseDirectory{
22
-
PLCURL: plcUrl,
22
+
PLCURL: identity.DefaultPLCURL,
23
23
HTTPClient: http.Client{
24
24
Timeout: time.Second * 10,
25
25
Transport: &http.Transport{
···
42
42
return &base
43
43
}
44
44
45
-
func RedisDirectory(url, plcUrl string) (identity.Directory, error) {
45
+
func RedisDirectory(url string) (identity.Directory, error) {
46
46
hitTTL := time.Hour * 24
47
47
errTTL := time.Second * 30
48
48
invalidHandleTTL := time.Minute * 5
49
-
return redisdir.NewRedisDirectory(
50
-
BaseDirectory(plcUrl),
51
-
url,
52
-
hitTTL,
53
-
errTTL,
54
-
invalidHandleTTL,
55
-
10000,
56
-
)
49
+
return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
57
50
}
58
51
59
-
func DefaultResolver(plcUrl string) *Resolver {
60
-
base := BaseDirectory(plcUrl)
61
-
cached := identity.NewCacheDirectory(base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5)
52
+
func DefaultResolver() *Resolver {
62
53
return &Resolver{
63
-
directory: &cached,
54
+
directory: identity.DefaultDirectory(),
64
55
}
65
56
}
66
57
67
-
func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) {
68
-
directory, err := RedisDirectory(redisUrl, plcUrl)
58
+
func RedisResolver(redisUrl string) (*Resolver, error) {
59
+
directory, err := RedisDirectory(redisUrl)
69
60
if err != nil {
70
61
return nil, err
71
62
}
+12
-109
input.css
+12
-109
input.css
···
134
134
}
135
135
136
136
.prose hr {
137
-
@apply my-2;
137
+
@apply my-2;
138
138
}
139
139
140
140
.prose li:has(input) {
141
-
@apply list-none;
141
+
@apply list-none;
142
142
}
143
143
144
144
.prose ul:has(input) {
145
-
@apply pl-2;
145
+
@apply pl-2;
146
146
}
147
147
148
148
.prose .heading .anchor {
149
-
@apply no-underline mx-2 opacity-0;
149
+
@apply no-underline mx-2 opacity-0;
150
150
}
151
151
152
152
.prose .heading:hover .anchor {
153
-
@apply opacity-70;
153
+
@apply opacity-70;
154
154
}
155
155
156
156
.prose .heading .anchor:hover {
157
-
@apply opacity-70;
157
+
@apply opacity-70;
158
158
}
159
159
160
160
.prose a.footnote-backref {
161
-
@apply no-underline;
162
-
}
163
-
164
-
.prose a.mention {
165
-
@apply no-underline hover:underline;
161
+
@apply no-underline;
166
162
}
167
163
168
164
.prose li {
169
-
@apply my-0 py-0;
165
+
@apply my-0 py-0;
170
166
}
171
167
172
-
.prose ul,
173
-
.prose ol {
174
-
@apply my-1 py-0;
168
+
.prose ul, .prose ol {
169
+
@apply my-1 py-0;
175
170
}
176
171
177
172
.prose img {
···
181
176
}
182
177
183
178
.prose input {
184
-
@apply inline-block my-0 mb-1 mx-1;
179
+
@apply inline-block my-0 mb-1 mx-1;
185
180
}
186
181
187
182
.prose input[type="checkbox"] {
188
183
@apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500;
189
184
}
190
-
191
-
/* Base callout */
192
-
details[data-callout] {
193
-
@apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4;
194
-
}
195
-
196
-
details[data-callout] > summary {
197
-
@apply font-bold cursor-pointer mb-1;
198
-
}
199
-
200
-
details[data-callout] > .callout-content {
201
-
@apply text-sm leading-snug;
202
-
}
203
-
204
-
/* Note (blue) */
205
-
details[data-callout="note" i] {
206
-
@apply border-blue-400 dark:border-blue-500;
207
-
}
208
-
details[data-callout="note" i] > summary {
209
-
@apply text-blue-700 dark:text-blue-400;
210
-
}
211
-
212
-
/* Important (purple) */
213
-
details[data-callout="important" i] {
214
-
@apply border-purple-400 dark:border-purple-500;
215
-
}
216
-
details[data-callout="important" i] > summary {
217
-
@apply text-purple-700 dark:text-purple-400;
218
-
}
219
-
220
-
/* Warning (yellow) */
221
-
details[data-callout="warning" i] {
222
-
@apply border-yellow-400 dark:border-yellow-500;
223
-
}
224
-
details[data-callout="warning" i] > summary {
225
-
@apply text-yellow-700 dark:text-yellow-400;
226
-
}
227
-
228
-
/* Caution (red) */
229
-
details[data-callout="caution" i] {
230
-
@apply border-red-400 dark:border-red-500;
231
-
}
232
-
details[data-callout="caution" i] > summary {
233
-
@apply text-red-700 dark:text-red-400;
234
-
}
235
-
236
-
/* Tip (green) */
237
-
details[data-callout="tip" i] {
238
-
@apply border-green-400 dark:border-green-500;
239
-
}
240
-
details[data-callout="tip" i] > summary {
241
-
@apply text-green-700 dark:text-green-400;
242
-
}
243
-
244
-
/* Optional: hide the disclosure arrow like GitHub */
245
-
details[data-callout] > summary::-webkit-details-marker {
246
-
display: none;
247
-
}
248
-
249
185
}
250
186
@layer utilities {
251
187
.error {
···
292
228
}
293
229
/* LineHighlight */
294
230
.chroma .hl {
295
-
@apply bg-amber-400/30 dark:bg-amber-500/20;
231
+
@apply bg-amber-400/30 dark:bg-amber-500/20;
296
232
}
297
233
298
234
/* LineNumbersTable */
···
929
865
text-decoration: underline;
930
866
}
931
867
}
932
-
933
-
actor-typeahead {
934
-
--color-background: #ffffff;
935
-
--color-border: #d1d5db;
936
-
--color-shadow: #000000;
937
-
--color-hover: #f9fafb;
938
-
--color-avatar-fallback: #e5e7eb;
939
-
--radius: 0.0;
940
-
--padding-menu: 0.0rem;
941
-
z-index: 1000;
942
-
}
943
-
944
-
actor-typeahead::part(handle) {
945
-
color: #111827;
946
-
}
947
-
948
-
actor-typeahead::part(menu) {
949
-
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
950
-
}
951
-
952
-
@media (prefers-color-scheme: dark) {
953
-
actor-typeahead {
954
-
--color-background: #1f2937;
955
-
--color-border: #4b5563;
956
-
--color-shadow: #000000;
957
-
--color-hover: #374151;
958
-
--color-avatar-fallback: #4b5563;
959
-
}
960
-
961
-
actor-typeahead::part(handle) {
962
-
color: #f9fafb;
963
-
}
964
-
}
+1
-1
jetstream/jetstream.go
+1
-1
jetstream/jetstream.go
···
114
114
115
115
sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc))
116
116
117
-
client, err := client.NewClient(j.cfg, logger, sched)
117
+
client, err := client.NewClient(j.cfg, log.New("jetstream"), sched)
118
118
if err != nil {
119
119
return fmt.Errorf("failed to create jetstream client: %w", err)
120
120
}
+1
-2
knotserver/config/config.go
+1
-2
knotserver/config/config.go
···
19
19
InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"`
20
20
DBPath string `env:"DB_PATH, default=knotserver.db"`
21
21
Hostname string `env:"HOSTNAME, required"`
22
-
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
23
22
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
24
23
Owner string `env:"OWNER, required"`
25
24
LogDids bool `env:"LOG_DIDS, default=true"`
···
42
41
Repo Repo `env:",prefix=KNOT_REPO_"`
43
42
Server Server `env:",prefix=KNOT_SERVER_"`
44
43
Git Git `env:",prefix=KNOT_GIT_"`
45
-
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"`
44
+
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"`
46
45
}
47
46
48
47
func Load(ctx context.Context) (*Config, error) {
+3
-2
knotserver/events.go
+3
-2
knotserver/events.go
···
8
8
"time"
9
9
10
10
"github.com/gorilla/websocket"
11
-
"tangled.org/core/log"
12
11
)
13
12
14
13
var upgrader = websocket.Upgrader{
···
17
16
}
18
17
19
18
func (h *Knot) Events(w http.ResponseWriter, r *http.Request) {
20
-
l := log.SubLogger(h.l, "eventstream")
19
+
l := h.l.With("handler", "OpLog")
21
20
l.Debug("received new connection")
22
21
23
22
conn, err := upgrader.Upgrade(w, r, nil)
···
76
75
}
77
76
case <-time.After(30 * time.Second):
78
77
// send a keep-alive
78
+
l.Debug("sent keepalive")
79
79
if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
80
80
l.Error("failed to write control", "err", err)
81
81
}
···
89
89
h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
90
90
return err
91
91
}
92
+
h.l.Debug("ops", "ops", events)
92
93
93
94
for _, event := range events {
94
95
// first extract the inner json into a map
-5
knotserver/git/branch.go
-5
knotserver/git/branch.go
+2
-71
knotserver/git/git.go
+2
-71
knotserver/git/git.go
···
3
3
import (
4
4
"archive/tar"
5
5
"bytes"
6
-
"errors"
7
6
"fmt"
8
7
"io"
9
8
"io/fs"
···
13
12
"time"
14
13
15
14
"github.com/go-git/go-git/v5"
16
-
"github.com/go-git/go-git/v5/config"
17
15
"github.com/go-git/go-git/v5/plumbing"
18
16
"github.com/go-git/go-git/v5/plumbing/object"
19
17
)
20
18
21
19
var (
22
-
ErrBinaryFile = errors.New("binary file")
23
-
ErrNotBinaryFile = errors.New("not binary file")
24
-
ErrMissingGitModules = errors.New("no .gitmodules file found")
25
-
ErrInvalidGitModules = errors.New("invalid .gitmodules file")
26
-
ErrNotSubmodule = errors.New("path is not a submodule")
20
+
ErrBinaryFile = fmt.Errorf("binary file")
21
+
ErrNotBinaryFile = fmt.Errorf("not binary file")
27
22
)
28
23
29
24
type GitRepo struct {
···
74
69
return nil, fmt.Errorf("opening %s: %w", path, err)
75
70
}
76
71
return &g, nil
77
-
}
78
-
79
-
// re-open a repository and update references
80
-
func (g *GitRepo) Refresh() error {
81
-
refreshed, err := PlainOpen(g.path)
82
-
if err != nil {
83
-
return err
84
-
}
85
-
86
-
*g = *refreshed
87
-
return nil
88
72
}
89
73
90
74
func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) {
···
193
177
defer reader.Close()
194
178
195
179
return io.ReadAll(reader)
196
-
}
197
-
198
-
// read and parse .gitmodules
199
-
func (g *GitRepo) Submodules() (*config.Modules, error) {
200
-
c, err := g.r.CommitObject(g.h)
201
-
if err != nil {
202
-
return nil, fmt.Errorf("commit object: %w", err)
203
-
}
204
-
205
-
tree, err := c.Tree()
206
-
if err != nil {
207
-
return nil, fmt.Errorf("tree: %w", err)
208
-
}
209
-
210
-
// read .gitmodules file
211
-
modulesEntry, err := tree.FindEntry(".gitmodules")
212
-
if err != nil {
213
-
return nil, fmt.Errorf("%w: %w", ErrMissingGitModules, err)
214
-
}
215
-
216
-
modulesFile, err := tree.TreeEntryFile(modulesEntry)
217
-
if err != nil {
218
-
return nil, fmt.Errorf("%w: failed to read file: %w", ErrInvalidGitModules, err)
219
-
}
220
-
221
-
content, err := modulesFile.Contents()
222
-
if err != nil {
223
-
return nil, fmt.Errorf("%w: failed to read contents: %w", ErrInvalidGitModules, err)
224
-
}
225
-
226
-
// parse .gitmodules
227
-
modules := config.NewModules()
228
-
if err = modules.Unmarshal([]byte(content)); err != nil {
229
-
return nil, fmt.Errorf("%w: failed to parse: %w", ErrInvalidGitModules, err)
230
-
}
231
-
232
-
return modules, nil
233
-
}
234
-
235
-
func (g *GitRepo) Submodule(path string) (*config.Submodule, error) {
236
-
modules, err := g.Submodules()
237
-
if err != nil {
238
-
return nil, err
239
-
}
240
-
241
-
for _, submodule := range modules.Submodules {
242
-
if submodule.Path == path {
243
-
return submodule, nil
244
-
}
245
-
}
246
-
247
-
// path is not a submodule
248
-
return nil, ErrNotSubmodule
249
180
}
250
181
251
182
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
+2
-21
knotserver/git/last_commit.go
+2
-21
knotserver/git/last_commit.go
···
30
30
commitCache = cache
31
31
}
32
32
33
-
// processReader wraps a reader and ensures the associated process is cleaned up
34
-
type processReader struct {
35
-
io.Reader
36
-
cmd *exec.Cmd
37
-
stdout io.ReadCloser
38
-
}
39
-
40
-
func (pr *processReader) Close() error {
41
-
if err := pr.stdout.Close(); err != nil {
42
-
return err
43
-
}
44
-
return pr.cmd.Wait()
45
-
}
46
-
47
-
func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.ReadCloser, error) {
33
+
func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.Reader, error) {
48
34
args := []string{}
49
35
args = append(args, "log")
50
36
args = append(args, g.h.String())
···
62
48
return nil, err
63
49
}
64
50
65
-
return &processReader{
66
-
Reader: stdout,
67
-
cmd: cmd,
68
-
stdout: stdout,
69
-
}, nil
51
+
return stdout, nil
70
52
}
71
53
72
54
type commit struct {
···
122
104
if err != nil {
123
105
return nil, err
124
106
}
125
-
defer output.Close() // Ensure the git process is properly cleaned up
126
107
127
108
reader := bufio.NewReader(output)
128
109
var current commit
+37
-150
knotserver/git/merge.go
+37
-150
knotserver/git/merge.go
···
4
4
"bytes"
5
5
"crypto/sha256"
6
6
"fmt"
7
-
"log"
8
7
"os"
9
8
"os/exec"
10
9
"regexp"
···
13
12
"github.com/dgraph-io/ristretto"
14
13
"github.com/go-git/go-git/v5"
15
14
"github.com/go-git/go-git/v5/plumbing"
16
-
"tangled.org/core/patchutil"
17
-
"tangled.org/core/types"
18
15
)
19
16
20
17
type MergeCheckCache struct {
···
35
32
mergeCheckCache = MergeCheckCache{cache}
36
33
}
37
34
38
-
func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string {
35
+
func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string {
39
36
sep := byte(':')
40
37
hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch))
41
38
return fmt.Sprintf("%x", hash)
···
52
49
}
53
50
}
54
51
55
-
func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) {
52
+
func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) {
56
53
key := m.cacheKey(g, patch, targetBranch)
57
54
val := m.cacheVal(mergeCheck)
58
55
m.cache.Set(key, val, 0)
59
56
}
60
57
61
-
func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) {
58
+
func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) {
62
59
key := m.cacheKey(g, patch, targetBranch)
63
60
if val, ok := m.cache.Get(key); ok {
64
61
if val == struct{}{} {
···
107
104
return fmt.Sprintf("merge failed: %s", e.Message)
108
105
}
109
106
110
-
func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) {
107
+
func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) {
111
108
tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
112
109
if err != nil {
113
110
return "", fmt.Errorf("failed to create temporary patch file: %w", err)
114
111
}
115
112
116
-
if _, err := tmpFile.Write([]byte(patchData)); err != nil {
113
+
if _, err := tmpFile.Write(patchData); err != nil {
117
114
tmpFile.Close()
118
115
os.Remove(tmpFile.Name())
119
116
return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
···
165
162
return nil
166
163
}
167
164
168
-
func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error {
165
+
func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error {
169
166
var stderr bytes.Buffer
170
167
var cmd *exec.Cmd
171
168
172
169
// configure default git user before merge
173
-
exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run()
174
-
exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run()
175
-
exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run()
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()
176
173
177
174
// if patch is a format-patch, apply using 'git am'
178
175
if opts.FormatPatch {
179
-
return g.applyMailbox(patchData)
180
-
}
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
+
}
181
184
182
-
// else, apply using 'git apply' and commit it manually
183
-
applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile)
184
-
applyCmd.Stderr = &stderr
185
-
if err := applyCmd.Run(); err != nil {
186
-
return fmt.Errorf("patch application failed: %s", stderr.String())
187
-
}
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
+
}
188
189
189
-
stageCmd := exec.Command("git", "-C", g.path, "add", ".")
190
-
if err := stageCmd.Run(); err != nil {
191
-
return fmt.Errorf("failed to stage changes: %w", err)
192
-
}
190
+
commitArgs := []string{"-C", tmpDir, "commit"}
193
191
194
-
commitArgs := []string{"-C", g.path, "commit"}
192
+
// Set author if provided
193
+
authorName := opts.AuthorName
194
+
authorEmail := opts.AuthorEmail
195
195
196
-
// Set author if provided
197
-
authorName := opts.AuthorName
198
-
authorEmail := opts.AuthorEmail
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
199
200
200
-
if authorName != "" && authorEmail != "" {
201
-
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
202
-
}
203
-
// else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
201
+
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
204
202
205
-
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
203
+
if opts.CommitBody != "" {
204
+
commitArgs = append(commitArgs, "-m", opts.CommitBody)
205
+
}
206
206
207
-
if opts.CommitBody != "" {
208
-
commitArgs = append(commitArgs, "-m", opts.CommitBody)
207
+
cmd = exec.Command("git", commitArgs...)
209
208
}
210
-
211
-
cmd = exec.Command("git", commitArgs...)
212
209
213
210
cmd.Stderr = &stderr
214
211
···
219
216
return nil
220
217
}
221
218
222
-
func (g *GitRepo) applyMailbox(patchData string) error {
223
-
fps, err := patchutil.ExtractPatches(patchData)
224
-
if err != nil {
225
-
return fmt.Errorf("failed to extract patches: %w", err)
226
-
}
227
-
228
-
// apply each patch one by one
229
-
// update the newly created commit object to add the change-id header
230
-
total := len(fps)
231
-
for i, p := range fps {
232
-
newCommit, err := g.applySingleMailbox(p)
233
-
if err != nil {
234
-
return err
235
-
}
236
-
237
-
log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String())
238
-
}
239
-
240
-
return nil
241
-
}
242
-
243
-
func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) {
244
-
tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw)
245
-
if err != nil {
246
-
return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err)
247
-
}
248
-
249
-
var stderr bytes.Buffer
250
-
cmd := exec.Command("git", "-C", g.path, "am", tmpPatch)
251
-
cmd.Stderr = &stderr
252
-
253
-
head, err := g.r.Head()
254
-
if err != nil {
255
-
return plumbing.ZeroHash, err
256
-
}
257
-
log.Println("head before apply", head.Hash().String())
258
-
259
-
if err := cmd.Run(); err != nil {
260
-
return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String())
261
-
}
262
-
263
-
if err := g.Refresh(); err != nil {
264
-
return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err)
265
-
}
266
-
267
-
head, err = g.r.Head()
268
-
if err != nil {
269
-
return plumbing.ZeroHash, err
270
-
}
271
-
log.Println("head after apply", head.Hash().String())
272
-
273
-
newHash := head.Hash()
274
-
if changeId, err := singlePatch.ChangeId(); err != nil {
275
-
// no change ID
276
-
} else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil {
277
-
return plumbing.ZeroHash, err
278
-
} else {
279
-
newHash = updatedHash
280
-
}
281
-
282
-
return newHash, nil
283
-
}
284
-
285
-
func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) {
286
-
log.Printf("updating change ID of %s to %s\n", hash.String(), changeId)
287
-
obj, err := g.r.CommitObject(hash)
288
-
if err != nil {
289
-
return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err)
290
-
}
291
-
292
-
// write the change-id header
293
-
obj.ExtraHeaders["change-id"] = []byte(changeId)
294
-
295
-
// create a new object
296
-
dest := g.r.Storer.NewEncodedObject()
297
-
if err := obj.Encode(dest); err != nil {
298
-
return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err)
299
-
}
300
-
301
-
// store the new object
302
-
newHash, err := g.r.Storer.SetEncodedObject(dest)
303
-
if err != nil {
304
-
return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err)
305
-
}
306
-
307
-
log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String())
308
-
309
-
// find the branch that HEAD is pointing to
310
-
ref, err := g.r.Head()
311
-
if err != nil {
312
-
return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err)
313
-
}
314
-
315
-
// and update that branch to point to new commit
316
-
if ref.Name().IsBranch() {
317
-
err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash))
318
-
if err != nil {
319
-
return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err)
320
-
}
321
-
}
322
-
323
-
// new hash of commit
324
-
return newHash, nil
325
-
}
326
-
327
-
func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error {
219
+
func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
328
220
if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
329
221
return val
330
222
}
···
352
244
return result
353
245
}
354
246
355
-
func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error {
247
+
func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error {
356
248
patchFile, err := g.createTempFileWithPatch(patchData)
357
249
if err != nil {
358
250
return &ErrMerge{
···
371
263
}
372
264
defer os.RemoveAll(tmpDir)
373
265
374
-
tmpRepo, err := PlainOpen(tmpDir)
375
-
if err != nil {
376
-
return err
377
-
}
378
-
379
-
if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil {
266
+
if err := g.applyPatch(tmpDir, patchFile, opts); err != nil {
380
267
return err
381
268
}
382
269
+13
-4
knotserver/git/tree.go
+13
-4
knotserver/git/tree.go
···
7
7
"path"
8
8
"time"
9
9
10
-
"github.com/go-git/go-git/v5/plumbing/filemode"
11
10
"github.com/go-git/go-git/v5/plumbing/object"
12
11
"tangled.org/core/types"
13
12
)
···
54
53
}
55
54
56
55
for _, e := range subtree.Entries {
56
+
mode, _ := e.Mode.ToOSFileMode()
57
57
sz, _ := subtree.Size(e.Name)
58
+
58
59
fpath := path.Join(parent, e.Name)
59
60
60
61
var lastCommit *types.LastCommitInfo
···
68
69
69
70
nts = append(nts, types.NiceTree{
70
71
Name: e.Name,
71
-
Mode: e.Mode.String(),
72
+
Mode: mode.String(),
73
+
IsFile: e.Mode.IsFile(),
72
74
Size: sz,
73
75
LastCommit: lastCommit,
74
76
})
···
124
126
default:
125
127
}
126
128
129
+
mode, err := e.Mode.ToOSFileMode()
130
+
if err != nil {
131
+
// TODO: log this
132
+
continue
133
+
}
134
+
127
135
if e.Mode.IsFile() {
128
-
if err := cb(e, currentTree, root); errors.Is(err, TerminateWalk) {
136
+
err = cb(e, currentTree, root)
137
+
if errors.Is(err, TerminateWalk) {
129
138
return err
130
139
}
131
140
}
132
141
133
142
// e is a directory
134
-
if e.Mode == filemode.Dir {
143
+
if mode.IsDir() {
135
144
subtree, err := currentTree.Tree(e.Name)
136
145
if err != nil {
137
146
return fmt.Errorf("sub tree %s: %w", e.Name, err)
+18
-18
knotserver/git.go
+18
-18
knotserver/git.go
···
13
13
"tangled.org/core/knotserver/git/service"
14
14
)
15
15
16
-
func (h *Knot) 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)
20
20
if err != nil {
21
21
gitError(w, "repository not found", http.StatusNotFound)
22
-
h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
22
+
d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
23
23
return
24
24
}
25
25
26
-
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName)
26
+
repoPath, err := securejoin.SecureJoin(d.c.Repo.ScanPath, repoName)
27
27
if err != nil {
28
28
gitError(w, "repository not found", http.StatusNotFound)
29
-
h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
29
+
d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
30
30
return
31
31
}
32
32
···
46
46
47
47
if err := cmd.InfoRefs(); err != nil {
48
48
gitError(w, err.Error(), http.StatusInternalServerError)
49
-
h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err)
49
+
d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err)
50
50
return
51
51
}
52
52
case "git-receive-pack":
53
-
h.RejectPush(w, r, name)
53
+
d.RejectPush(w, r, name)
54
54
default:
55
55
gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden)
56
56
}
57
57
}
58
58
59
-
func (h *Knot) 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
-
repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
62
+
repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
63
63
if err != nil {
64
64
gitError(w, err.Error(), http.StatusInternalServerError)
65
-
h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
65
+
d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
66
66
return
67
67
}
68
68
···
77
77
gzipReader, err := gzip.NewReader(r.Body)
78
78
if err != nil {
79
79
gitError(w, err.Error(), http.StatusInternalServerError)
80
-
h.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
80
+
d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
81
81
return
82
82
}
83
83
defer gzipReader.Close()
···
88
88
w.Header().Set("Connection", "Keep-Alive")
89
89
w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
90
90
91
-
h.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
91
+
d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
92
92
93
93
cmd := service.ServiceCommand{
94
94
GitProtocol: r.Header.Get("Git-Protocol"),
···
100
100
w.WriteHeader(http.StatusOK)
101
101
102
102
if err := cmd.UploadPack(); err != nil {
103
-
h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
103
+
d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
104
104
return
105
105
}
106
106
}
107
107
108
-
func (h *Knot) 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
-
_, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
111
+
_, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
112
112
if err != nil {
113
113
gitError(w, err.Error(), http.StatusForbidden)
114
-
h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err)
114
+
d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err)
115
115
return
116
116
}
117
117
118
-
h.RejectPush(w, r, name)
118
+
d.RejectPush(w, r, name)
119
119
}
120
120
121
-
func (h *Knot) 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")
···
131
131
ownerHandle := r.Header.Get("x-tangled-repo-owner-handle")
132
132
ownerHandle = strings.TrimPrefix(ownerHandle, "@")
133
133
if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") {
134
-
hostname := h.c.Server.Hostname
134
+
hostname := d.c.Server.Hostname
135
135
if strings.Contains(hostname, ":") {
136
136
hostname = strings.Split(hostname, ":")[0]
137
137
}
+8
-4
knotserver/ingester.go
+8
-4
knotserver/ingester.go
···
16
16
"github.com/bluesky-social/jetstream/pkg/models"
17
17
securejoin "github.com/cyphar/filepath-securejoin"
18
18
"tangled.org/core/api/tangled"
19
+
"tangled.org/core/idresolver"
19
20
"tangled.org/core/knotserver/db"
20
21
"tangled.org/core/knotserver/git"
21
22
"tangled.org/core/log"
···
119
120
}
120
121
121
122
// resolve this aturi to extract the repo record
122
-
ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
123
+
resolver := idresolver.DefaultResolver()
124
+
ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
123
125
if err != nil || ident.Handle.IsInvalidHandle() {
124
126
return fmt.Errorf("failed to resolve handle: %w", err)
125
127
}
···
161
163
162
164
var pipeline workflow.RawPipeline
163
165
for _, e := range workflowDir {
164
-
if !e.IsFile() {
166
+
if !e.IsFile {
165
167
continue
166
168
}
167
169
···
231
233
return err
232
234
}
233
235
234
-
subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject)
236
+
resolver := idresolver.DefaultResolver()
237
+
238
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
235
239
if err != nil || subjectId.Handle.IsInvalidHandle() {
236
240
return err
237
241
}
238
242
239
243
// TODO: fix this for good, we need to fetch the record here unfortunately
240
244
// resolve this aturi to extract the repo record
241
-
owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
245
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
242
246
if err != nil || owner.Handle.IsInvalidHandle() {
243
247
return fmt.Errorf("failed to resolve handle: %w", err)
244
248
}
+8
-154
knotserver/internal.go
+8
-154
knotserver/internal.go
···
13
13
securejoin "github.com/cyphar/filepath-securejoin"
14
14
"github.com/go-chi/chi/v5"
15
15
"github.com/go-chi/chi/v5/middleware"
16
-
"github.com/go-git/go-git/v5/plumbing"
17
16
"tangled.org/core/api/tangled"
18
17
"tangled.org/core/hook"
19
-
"tangled.org/core/idresolver"
20
18
"tangled.org/core/knotserver/config"
21
19
"tangled.org/core/knotserver/db"
22
20
"tangled.org/core/knotserver/git"
23
-
"tangled.org/core/log"
24
21
"tangled.org/core/notifier"
25
22
"tangled.org/core/rbac"
26
23
"tangled.org/core/workflow"
27
24
)
28
25
29
26
type InternalHandle struct {
30
-
db *db.DB
31
-
c *config.Config
32
-
e *rbac.Enforcer
33
-
l *slog.Logger
34
-
n *notifier.Notifier
35
-
res *idresolver.Resolver
27
+
db *db.DB
28
+
c *config.Config
29
+
e *rbac.Enforcer
30
+
l *slog.Logger
31
+
n *notifier.Notifier
36
32
}
37
33
38
34
func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
···
68
64
writeJSON(w, data)
69
65
}
70
66
71
-
// response in text/plain format
72
-
// the body will be qualified repository path on success/push-denied
73
-
// or an error message when process failed
74
-
func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) {
75
-
l := h.l.With("handler", "PostReceiveHook")
76
-
77
-
var (
78
-
incomingUser = r.URL.Query().Get("user")
79
-
repo = r.URL.Query().Get("repo")
80
-
gitCommand = r.URL.Query().Get("gitCmd")
81
-
)
82
-
83
-
if incomingUser == "" || repo == "" || gitCommand == "" {
84
-
w.WriteHeader(http.StatusBadRequest)
85
-
l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand)
86
-
fmt.Fprintln(w, "invalid internal request")
87
-
return
88
-
}
89
-
90
-
// did:foo/repo-name or
91
-
// handle/repo-name or
92
-
// any of the above with a leading slash (/)
93
-
components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/")
94
-
l.Info("command components", "components", components)
95
-
96
-
if len(components) != 2 {
97
-
w.WriteHeader(http.StatusBadRequest)
98
-
l.Error("invalid repo format", "components", components)
99
-
fmt.Fprintln(w, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
100
-
return
101
-
}
102
-
repoOwner := components[0]
103
-
repoName := components[1]
104
-
105
-
resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl)
106
-
107
-
repoOwnerIdent, err := resolver.ResolveIdent(r.Context(), repoOwner)
108
-
if err != nil || repoOwnerIdent.Handle.IsInvalidHandle() {
109
-
l.Error("Error resolving handle", "handle", repoOwner, "err", err)
110
-
w.WriteHeader(http.StatusInternalServerError)
111
-
fmt.Fprintf(w, "error resolving handle: invalid handle\n")
112
-
return
113
-
}
114
-
repoOwnerDid := repoOwnerIdent.DID.String()
115
-
116
-
qualifiedRepo, _ := securejoin.SecureJoin(repoOwnerDid, repoName)
117
-
118
-
if gitCommand == "git-receive-pack" {
119
-
ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo)
120
-
if err != nil || !ok {
121
-
w.WriteHeader(http.StatusForbidden)
122
-
fmt.Fprint(w, repo)
123
-
return
124
-
}
125
-
}
126
-
127
-
w.WriteHeader(http.StatusOK)
128
-
fmt.Fprint(w, qualifiedRepo)
129
-
}
130
-
131
67
type PushOptions struct {
132
68
skipCi bool
133
69
verboseCi bool
···
182
118
// non-fatal
183
119
}
184
120
185
-
err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName)
186
-
if err != nil {
187
-
l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
188
-
// non-fatal
189
-
}
190
-
191
121
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
192
122
if err != nil {
193
123
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
···
243
173
return errors.Join(errs, h.db.InsertEvent(event, h.n))
244
174
}
245
175
246
-
func (h *InternalHandle) triggerPipeline(
247
-
clientMsgs *[]string,
248
-
line git.PostReceiveLine,
249
-
gitUserDid string,
250
-
repoDid string,
251
-
repoName string,
252
-
pushOptions PushOptions,
253
-
) error {
176
+
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
254
177
if pushOptions.skipCi {
255
178
return nil
256
179
}
···
277
200
278
201
var pipeline workflow.RawPipeline
279
202
for _, e := range workflowDir {
280
-
if !e.IsFile() {
203
+
if !e.IsFile {
281
204
continue
282
205
}
283
206
···
345
268
return h.db.InsertEvent(event, h.n)
346
269
}
347
270
348
-
func (h *InternalHandle) emitCompareLink(
349
-
clientMsgs *[]string,
350
-
line git.PostReceiveLine,
351
-
repoDid string,
352
-
repoName string,
353
-
) error {
354
-
// this is a second push to a branch, don't reply with the link again
355
-
if !line.OldSha.IsZero() {
356
-
return nil
357
-
}
358
-
359
-
// the ref was not updated to a new hash, don't reply with the link
360
-
//
361
-
// NOTE: do we need this?
362
-
if line.NewSha.String() == line.OldSha.String() {
363
-
return nil
364
-
}
365
-
366
-
pushedRef := plumbing.ReferenceName(line.Ref)
367
-
368
-
userIdent, err := h.res.ResolveIdent(context.Background(), repoDid)
369
-
user := repoDid
370
-
if err == nil {
371
-
user = userIdent.Handle.String()
372
-
}
373
-
374
-
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
375
-
if err != nil {
376
-
return err
377
-
}
378
-
379
-
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
380
-
if err != nil {
381
-
return err
382
-
}
383
-
384
-
gr, err := git.PlainOpen(repoPath)
385
-
if err != nil {
386
-
return err
387
-
}
388
-
389
-
defaultBranch, err := gr.FindMainBranch()
390
-
if err != nil {
391
-
return err
392
-
}
393
-
394
-
// pushing to default branch
395
-
if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) {
396
-
return nil
397
-
}
398
-
399
-
// pushing a tag, don't prompt the user the open a PR
400
-
if pushedRef.IsTag() {
401
-
return nil
402
-
}
403
-
404
-
ZWS := "\u200B"
405
-
*clientMsgs = append(*clientMsgs, ZWS)
406
-
*clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
407
-
*clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
408
-
*clientMsgs = append(*clientMsgs, ZWS)
409
-
return nil
410
-
}
411
-
412
-
func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
271
+
func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler {
413
272
r := chi.NewRouter()
414
-
l := log.FromContext(ctx)
415
-
l = log.SubLogger(l, "internal")
416
-
res := idresolver.DefaultResolver(c.Server.PlcUrl)
417
273
418
274
h := InternalHandle{
419
275
db,
···
421
277
e,
422
278
l,
423
279
n,
424
-
res,
425
280
}
426
281
427
282
r.Get("/push-allowed", h.PushAllowed)
428
283
r.Get("/keys", h.InternalKeys)
429
-
r.Get("/guard", h.Guard)
430
284
r.Post("/hooks/post-receive", h.PostReceiveHook)
431
285
r.Mount("/debug", middleware.Profiler())
432
286
-53
knotserver/middleware.go
-53
knotserver/middleware.go
···
1
-
package knotserver
2
-
3
-
import (
4
-
"log/slog"
5
-
"net/http"
6
-
"time"
7
-
)
8
-
9
-
func (h *Knot) RequestLogger(next http.Handler) http.Handler {
10
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
11
-
start := time.Now()
12
-
13
-
next.ServeHTTP(w, r)
14
-
15
-
// Build query params as slog.Attrs for the group
16
-
queryParams := r.URL.Query()
17
-
queryAttrs := make([]any, 0, len(queryParams))
18
-
for key, values := range queryParams {
19
-
if len(values) == 1 {
20
-
queryAttrs = append(queryAttrs, slog.String(key, values[0]))
21
-
} else {
22
-
queryAttrs = append(queryAttrs, slog.Any(key, values))
23
-
}
24
-
}
25
-
26
-
h.l.LogAttrs(r.Context(), slog.LevelInfo, "",
27
-
slog.Group("request",
28
-
slog.String("method", r.Method),
29
-
slog.String("path", r.URL.Path),
30
-
slog.Group("query", queryAttrs...),
31
-
slog.Duration("duration", time.Since(start)),
32
-
),
33
-
)
34
-
})
35
-
}
36
-
37
-
func (h *Knot) CORS(next http.Handler) http.Handler {
38
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39
-
// Set CORS headers
40
-
w.Header().Set("Access-Control-Allow-Origin", "*")
41
-
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
42
-
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
43
-
w.Header().Set("Access-Control-Max-Age", "86400")
44
-
45
-
// Handle preflight requests
46
-
if r.Method == "OPTIONS" {
47
-
w.WriteHeader(http.StatusOK)
48
-
return
49
-
}
50
-
51
-
next.ServeHTTP(w, r)
52
-
})
53
-
}
+10
-18
knotserver/router.go
+10
-18
knotserver/router.go
···
12
12
"tangled.org/core/knotserver/config"
13
13
"tangled.org/core/knotserver/db"
14
14
"tangled.org/core/knotserver/xrpc"
15
-
"tangled.org/core/log"
15
+
tlog "tangled.org/core/log"
16
16
"tangled.org/core/notifier"
17
17
"tangled.org/core/rbac"
18
18
"tangled.org/core/xrpc/serviceauth"
···
28
28
resolver *idresolver.Resolver
29
29
}
30
30
31
-
func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier) (http.Handler, error) {
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
+
32
34
h := Knot{
33
35
c: c,
34
36
db: db,
35
37
e: e,
36
-
l: log.FromContext(ctx),
38
+
l: l,
37
39
jc: jc,
38
40
n: n,
39
-
resolver: idresolver.DefaultResolver(c.Server.PlcUrl),
41
+
resolver: idresolver.DefaultResolver(),
40
42
}
41
43
42
44
err := e.AddKnot(rbac.ThisServer)
···
65
67
return nil, fmt.Errorf("failed to start jetstream: %w", err)
66
68
}
67
69
68
-
return h.Router(), nil
69
-
}
70
-
71
-
func (h *Knot) Router() http.Handler {
72
-
r := chi.NewRouter()
73
-
74
-
r.Use(h.CORS)
75
-
r.Use(h.RequestLogger)
76
-
77
70
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
78
71
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
79
72
})
···
93
86
// Socket that streams git oplogs
94
87
r.Get("/events", h.Events)
95
88
96
-
return r
89
+
return r, nil
97
90
}
98
91
99
92
func (h *Knot) XrpcRouter() http.Handler {
100
-
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
93
+
logger := tlog.New("knots")
101
94
102
-
l := log.SubLogger(h.l, "xrpc")
95
+
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
103
96
104
97
xrpc := &xrpc.Xrpc{
105
98
Config: h.c,
106
99
Db: h.db,
107
100
Ingester: h.jc,
108
101
Enforcer: h.e,
109
-
Logger: l,
102
+
Logger: logger,
110
103
Notifier: h.n,
111
104
Resolver: h.resolver,
112
105
ServiceAuth: serviceAuth,
113
106
}
114
-
115
107
return xrpc.Router()
116
108
}
117
109
+4
-5
knotserver/server.go
+4
-5
knotserver/server.go
···
43
43
44
44
func Run(ctx context.Context, cmd *cli.Command) error {
45
45
logger := log.FromContext(ctx)
46
-
logger = log.SubLogger(logger, cmd.Name)
47
-
ctx = log.IntoContext(ctx, logger)
46
+
iLogger := log.New("knotserver/internal")
48
47
49
48
c, err := config.Load(ctx)
50
49
if err != nil {
···
81
80
tangled.KnotMemberNSID,
82
81
tangled.RepoPullNSID,
83
82
tangled.RepoCollaboratorNSID,
84
-
}, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids)
83
+
}, nil, logger, db, true, c.Server.LogDids)
85
84
if err != nil {
86
85
logger.Error("failed to setup jetstream", "error", err)
87
86
}
88
87
89
88
notifier := notifier.New()
90
89
91
-
mux, err := Setup(ctx, c, db, e, jc, ¬ifier)
90
+
mux, err := Setup(ctx, c, db, e, jc, logger, ¬ifier)
92
91
if err != nil {
93
92
return fmt.Errorf("failed to setup server: %w", err)
94
93
}
95
94
96
-
imux := Internal(ctx, c, db, e, ¬ifier)
95
+
imux := Internal(ctx, c, db, e, iLogger, ¬ifier)
97
96
98
97
logger.Info("starting internal server", "address", c.Server.InternalListenAddr)
99
98
go http.ListenAndServe(c.Server.InternalListenAddr, imux)
-87
knotserver/xrpc/delete_branch.go
-87
knotserver/xrpc/delete_branch.go
···
1
-
package xrpc
2
-
3
-
import (
4
-
"encoding/json"
5
-
"fmt"
6
-
"net/http"
7
-
8
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"github.com/bluesky-social/indigo/xrpc"
11
-
securejoin "github.com/cyphar/filepath-securejoin"
12
-
"tangled.org/core/api/tangled"
13
-
"tangled.org/core/knotserver/git"
14
-
"tangled.org/core/rbac"
15
-
16
-
xrpcerr "tangled.org/core/xrpc/errors"
17
-
)
18
-
19
-
func (x *Xrpc) DeleteBranch(w http.ResponseWriter, r *http.Request) {
20
-
l := x.Logger
21
-
fail := func(e xrpcerr.XrpcError) {
22
-
l.Error("failed", "kind", e.Tag, "error", e.Message)
23
-
writeError(w, e, http.StatusBadRequest)
24
-
}
25
-
26
-
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
27
-
if !ok {
28
-
fail(xrpcerr.MissingActorDidError)
29
-
return
30
-
}
31
-
32
-
var data tangled.RepoDeleteBranch_Input
33
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
34
-
fail(xrpcerr.GenericError(err))
35
-
return
36
-
}
37
-
38
-
// unfortunately we have to resolve repo-at here
39
-
repoAt, err := syntax.ParseATURI(data.Repo)
40
-
if err != nil {
41
-
fail(xrpcerr.InvalidRepoError(data.Repo))
42
-
return
43
-
}
44
-
45
-
// resolve this aturi to extract the repo record
46
-
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
47
-
if err != nil || ident.Handle.IsInvalidHandle() {
48
-
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
49
-
return
50
-
}
51
-
52
-
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
53
-
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
54
-
if err != nil {
55
-
fail(xrpcerr.GenericError(err))
56
-
return
57
-
}
58
-
59
-
repo := resp.Value.Val.(*tangled.Repo)
60
-
didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
61
-
if err != nil {
62
-
fail(xrpcerr.GenericError(err))
63
-
return
64
-
}
65
-
66
-
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
67
-
l.Error("insufficent permissions", "did", actorDid.String(), "repo", didPath)
68
-
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
69
-
return
70
-
}
71
-
72
-
path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath)
73
-
gr, err := git.PlainOpen(path)
74
-
if err != nil {
75
-
fail(xrpcerr.GenericError(err))
76
-
return
77
-
}
78
-
79
-
err = gr.DeleteBranch(data.Branch)
80
-
if err != nil {
81
-
l.Error("deleting branch", "error", err.Error(), "branch", data.Branch)
82
-
writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError)
83
-
return
84
-
}
85
-
86
-
w.WriteHeader(http.StatusOK)
87
-
}
+1
-1
knotserver/xrpc/merge.go
+1
-1
knotserver/xrpc/merge.go
···
85
85
mo.CommitterEmail = x.Config.Git.UserEmail
86
86
mo.FormatPatch = patchutil.IsFormatPatch(data.Patch)
87
87
88
-
err = gr.MergeWithOptions(data.Patch, data.Branch, mo)
88
+
err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
89
89
if err != nil {
90
90
var mergeErr *git.ErrMerge
91
91
if errors.As(err, &mergeErr) {
+1
-3
knotserver/xrpc/merge_check.go
+1
-3
knotserver/xrpc/merge_check.go
···
51
51
return
52
52
}
53
53
54
-
err = gr.MergeCheck(data.Patch, data.Branch)
54
+
err = gr.MergeCheck([]byte(data.Patch), data.Branch)
55
55
56
56
response := tangled.RepoMergeCheck_Output{
57
57
Is_conflicted: false,
···
80
80
response.Error = &errMsg
81
81
}
82
82
}
83
-
84
-
l.Debug("merge check response", "isConflicted", response.Is_conflicted, "err", response.Error, "conflicts", response.Conflicts)
85
83
86
84
w.Header().Set("Content-Type", "application/json")
87
85
w.WriteHeader(http.StatusOK)
+2
-21
knotserver/xrpc/repo_blob.go
+2
-21
knotserver/xrpc/repo_blob.go
···
42
42
return
43
43
}
44
44
45
-
// first check if this path is a submodule
46
-
submodule, err := gr.Submodule(treePath)
47
-
if err != nil {
48
-
// this is okay, continue and try to treat it as a regular file
49
-
} else {
50
-
response := tangled.RepoBlob_Output{
51
-
Ref: ref,
52
-
Path: treePath,
53
-
Submodule: &tangled.RepoBlob_Submodule{
54
-
Name: submodule.Name,
55
-
Url: submodule.URL,
56
-
Branch: &submodule.Branch,
57
-
},
58
-
}
59
-
writeJson(w, response)
60
-
return
61
-
}
62
-
63
45
contents, err := gr.RawContent(treePath)
64
46
if err != nil {
65
47
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
···
119
101
var encoding string
120
102
121
103
isBinary := !isTextual(mimeType)
122
-
size := int64(len(contents))
123
104
124
105
if isBinary {
125
106
content = base64.StdEncoding.EncodeToString(contents)
···
132
113
response := tangled.RepoBlob_Output{
133
114
Ref: ref,
134
115
Path: treePath,
135
-
Content: &content,
116
+
Content: content,
136
117
Encoding: &encoding,
137
-
Size: &size,
118
+
Size: &[]int64{int64(len(contents))}[0],
138
119
IsBinary: &isBinary,
139
120
}
140
121
+4
-20
knotserver/xrpc/repo_compare.go
+4
-20
knotserver/xrpc/repo_compare.go
···
4
4
"fmt"
5
5
"net/http"
6
6
7
-
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
7
"tangled.org/core/knotserver/git"
9
8
"tangled.org/core/types"
10
9
xrpcerr "tangled.org/core/xrpc/errors"
···
72
71
return
73
72
}
74
73
75
-
var combinedPatch []*gitdiff.File
76
-
var combinedPatchRaw string
77
-
// we need the combined patch
78
-
if len(formatPatch) >= 2 {
79
-
diffTree, err := gr.DiffTree(commit1, commit2)
80
-
if err != nil {
81
-
x.Logger.Error("error comparing revisions", "msg", err.Error())
82
-
} else {
83
-
combinedPatch = diffTree.Diff
84
-
combinedPatchRaw = diffTree.Patch
85
-
}
86
-
}
87
-
88
74
response := types.RepoFormatPatchResponse{
89
-
Rev1: commit1.Hash.String(),
90
-
Rev2: commit2.Hash.String(),
91
-
FormatPatch: formatPatch,
92
-
FormatPatchRaw: rawPatch,
93
-
CombinedPatch: combinedPatch,
94
-
CombinedPatchRaw: combinedPatchRaw,
75
+
Rev1: commit1.Hash.String(),
76
+
Rev2: commit2.Hash.String(),
77
+
FormatPatch: formatPatch,
78
+
Patch: rawPatch,
95
79
}
96
80
97
81
writeJson(w, response)
+5
-3
knotserver/xrpc/repo_tree.go
+5
-3
knotserver/xrpc/repo_tree.go
···
67
67
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
68
68
for i, file := range files {
69
69
entry := &tangled.RepoTree_TreeEntry{
70
-
Name: file.Name,
71
-
Mode: file.Mode,
72
-
Size: file.Size,
70
+
Name: file.Name,
71
+
Mode: file.Mode,
72
+
Size: file.Size,
73
+
Is_file: file.IsFile,
74
+
Is_subtree: file.IsSubtree,
73
75
}
74
76
75
77
if file.LastCommit != nil {
-1
knotserver/xrpc/xrpc.go
-1
knotserver/xrpc/xrpc.go
···
38
38
r.Use(x.ServiceAuth.VerifyServiceAuth)
39
39
40
40
r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
41
-
r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch)
42
41
r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo)
43
42
r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo)
44
43
r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
-5
lexicons/actor/profile.json
-5
lexicons/actor/profile.json
+5
-49
lexicons/repo/blob.json
+5
-49
lexicons/repo/blob.json
···
6
6
"type": "query",
7
7
"parameters": {
8
8
"type": "params",
9
-
"required": [
10
-
"repo",
11
-
"ref",
12
-
"path"
13
-
],
9
+
"required": ["repo", "ref", "path"],
14
10
"properties": {
15
11
"repo": {
16
12
"type": "string",
···
35
31
"encoding": "application/json",
36
32
"schema": {
37
33
"type": "object",
38
-
"required": [
39
-
"ref",
40
-
"path"
41
-
],
34
+
"required": ["ref", "path", "content"],
42
35
"properties": {
43
36
"ref": {
44
37
"type": "string",
···
55
48
"encoding": {
56
49
"type": "string",
57
50
"description": "Content encoding",
58
-
"enum": [
59
-
"utf-8",
60
-
"base64"
61
-
]
51
+
"enum": ["utf-8", "base64"]
62
52
},
63
53
"size": {
64
54
"type": "integer",
···
71
61
"mimeType": {
72
62
"type": "string",
73
63
"description": "MIME type of the file"
74
-
},
75
-
"submodule": {
76
-
"type": "ref",
77
-
"ref": "#submodule",
78
-
"description": "Submodule information if path is a submodule"
79
64
},
80
65
"lastCommit": {
81
66
"type": "ref",
···
105
90
},
106
91
"lastCommit": {
107
92
"type": "object",
108
-
"required": [
109
-
"hash",
110
-
"message",
111
-
"when"
112
-
],
93
+
"required": ["hash", "message", "when"],
113
94
"properties": {
114
95
"hash": {
115
96
"type": "string",
···
136
117
},
137
118
"signature": {
138
119
"type": "object",
139
-
"required": [
140
-
"name",
141
-
"email",
142
-
"when"
143
-
],
120
+
"required": ["name", "email", "when"],
144
121
"properties": {
145
122
"name": {
146
123
"type": "string",
···
154
131
"type": "string",
155
132
"format": "datetime",
156
133
"description": "Author timestamp"
157
-
}
158
-
}
159
-
},
160
-
"submodule": {
161
-
"type": "object",
162
-
"required": [
163
-
"name",
164
-
"url"
165
-
],
166
-
"properties": {
167
-
"name": {
168
-
"type": "string",
169
-
"description": "Submodule name"
170
-
},
171
-
"url": {
172
-
"type": "string",
173
-
"description": "Submodule repository URL"
174
-
},
175
-
"branch": {
176
-
"type": "string",
177
-
"description": "Branch to track in the submodule"
178
134
}
179
135
}
180
136
}
-30
lexicons/repo/deleteBranch.json
-30
lexicons/repo/deleteBranch.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.deleteBranch",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Delete a branch on this repository",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"branch"
15
-
],
16
-
"properties": {
17
-
"repo": {
18
-
"type": "string",
19
-
"format": "at-uri"
20
-
},
21
-
"branch": {
22
-
"type": "string"
23
-
}
24
-
}
25
-
}
26
-
}
27
-
}
28
-
}
29
-
}
30
-
-15
lexicons/repo/repo.json
-15
lexicons/repo/repo.json
···
32
32
"minGraphemes": 1,
33
33
"maxGraphemes": 140
34
34
},
35
-
"website": {
36
-
"type": "string",
37
-
"format": "uri",
38
-
"description": "Any URI related to the repo"
39
-
},
40
-
"topics": {
41
-
"type": "array",
42
-
"description": "Topics related to the repo",
43
-
"items": {
44
-
"type": "string",
45
-
"minLength": 1,
46
-
"maxLength": 50
47
-
},
48
-
"maxLength": 50
49
-
},
50
35
"source": {
51
36
"type": "string",
52
37
"format": "uri",
+9
-1
lexicons/repo/tree.json
+9
-1
lexicons/repo/tree.json
···
91
91
},
92
92
"treeEntry": {
93
93
"type": "object",
94
-
"required": ["name", "mode", "size"],
94
+
"required": ["name", "mode", "size", "is_file", "is_subtree"],
95
95
"properties": {
96
96
"name": {
97
97
"type": "string",
···
104
104
"size": {
105
105
"type": "integer",
106
106
"description": "File size in bytes"
107
+
},
108
+
"is_file": {
109
+
"type": "boolean",
110
+
"description": "Whether this entry is a file"
111
+
},
112
+
"is_subtree": {
113
+
"type": "boolean",
114
+
"description": "Whether this entry is a directory/subtree"
107
115
},
108
116
"last_commit": {
109
117
"type": "ref",
+9
-23
log/log.go
+9
-23
log/log.go
···
4
4
"context"
5
5
"log/slog"
6
6
"os"
7
-
8
-
"github.com/charmbracelet/log"
9
7
)
10
8
9
+
// NewHandler sets up a new slog.Handler with the service name
10
+
// as an attribute
11
11
func NewHandler(name string) slog.Handler {
12
-
return log.NewWithOptions(os.Stderr, log.Options{
13
-
ReportTimestamp: true,
14
-
Prefix: name,
15
-
Level: log.DebugLevel,
12
+
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
13
+
Level: slog.LevelDebug,
16
14
})
15
+
16
+
var attrs []slog.Attr
17
+
attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
18
+
handler.WithAttrs(attrs)
19
+
return handler
17
20
}
18
21
19
22
func New(name string) *slog.Logger {
···
46
49
47
50
return slog.Default()
48
51
}
49
-
50
-
// sublogger derives a new logger from an existing one by appending a suffix to its prefix.
51
-
func SubLogger(base *slog.Logger, suffix string) *slog.Logger {
52
-
// try to get the underlying charmbracelet logger
53
-
if cl, ok := base.Handler().(*log.Logger); ok {
54
-
prefix := cl.GetPrefix()
55
-
if prefix != "" {
56
-
prefix = prefix + "/" + suffix
57
-
} else {
58
-
prefix = suffix
59
-
}
60
-
return slog.New(NewHandler(prefix))
61
-
}
62
-
63
-
// Fallback: no known handler type
64
-
return slog.New(NewHandler(suffix))
65
-
}
+17
-149
nix/gomod2nix.toml
+17
-149
nix/gomod2nix.toml
···
13
13
[mod."github.com/ProtonMail/go-crypto"]
14
14
version = "v1.3.0"
15
15
hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI="
16
-
[mod."github.com/RoaringBitmap/roaring/v2"]
17
-
version = "v2.4.5"
18
-
hash = "sha256-igWY0S1PTolQkfctYcmVJioJyV1pk2V81X6o6BA1XQA="
19
16
[mod."github.com/alecthomas/assert/v2"]
20
17
version = "v2.11.0"
21
18
hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU="
···
32
29
[mod."github.com/avast/retry-go/v4"]
33
30
version = "v4.6.1"
34
31
hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k="
35
-
[mod."github.com/aymanbagabas/go-osc52/v2"]
36
-
version = "v2.0.1"
37
-
hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg="
38
32
[mod."github.com/aymerick/douceur"]
39
33
version = "v0.2.0"
40
34
hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE="
41
35
[mod."github.com/beorn7/perks"]
42
36
version = "v1.0.1"
43
37
hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4="
44
-
[mod."github.com/bits-and-blooms/bitset"]
45
-
version = "v1.22.0"
46
-
hash = "sha256-lY1K29h4vlAmJVvwKgbTG8BTACYGjFaginCszN+ST6w="
47
-
[mod."github.com/blevesearch/bleve/v2"]
48
-
version = "v2.5.3"
49
-
hash = "sha256-DkpX43WMpB8+9KCibdNjyf6N/1a51xJTfGF97xdoCAQ="
50
-
[mod."github.com/blevesearch/bleve_index_api"]
51
-
version = "v1.2.8"
52
-
hash = "sha256-LyGDBRvK2GThgUFLZoAbDOOKP1M9Z8oy0E2M6bHZdrk="
53
-
[mod."github.com/blevesearch/geo"]
54
-
version = "v0.2.4"
55
-
hash = "sha256-W1OV/pvqzJC28VJomGnIU/HeBZ689+p54vWdZ1z/bxc="
56
-
[mod."github.com/blevesearch/go-faiss"]
57
-
version = "v1.0.25"
58
-
hash = "sha256-bcm976UX22aNIuSjBxFaYMKTltO9lbqyeG4Z3KVG3/Y="
59
-
[mod."github.com/blevesearch/go-porterstemmer"]
60
-
version = "v1.0.3"
61
-
hash = "sha256-hUjo6g1ehUD1awBmta0ji/xoooD2qG7O22HIeSQiRFo="
62
-
[mod."github.com/blevesearch/gtreap"]
63
-
version = "v0.1.1"
64
-
hash = "sha256-B4p/5RnECRfV4yOiSQDLMHb23uI7lsQDePhNK+zjbF4="
65
-
[mod."github.com/blevesearch/mmap-go"]
66
-
version = "v1.0.4"
67
-
hash = "sha256-8y0nMAE9goKjYhR/FFEvtbP7cvM46xneE461L1Jn2Pg="
68
-
[mod."github.com/blevesearch/scorch_segment_api/v2"]
69
-
version = "v2.3.10"
70
-
hash = "sha256-BcBRjVOrsYySdsdgEjS3qHFm/c58KUNJepRPUO0lFmY="
71
-
[mod."github.com/blevesearch/segment"]
72
-
version = "v0.9.1"
73
-
hash = "sha256-0EAT737kNxl8IJFGl2SD9mOzxolONGgpfaYEGr7JXkQ="
74
-
[mod."github.com/blevesearch/snowballstem"]
75
-
version = "v0.9.0"
76
-
hash = "sha256-NQsXrhXcYXn4jQcvwjwLc96SGMRcqVlrR6hYKWGk7/s="
77
-
[mod."github.com/blevesearch/upsidedown_store_api"]
78
-
version = "v1.0.2"
79
-
hash = "sha256-P69Mnh6YR5RI73bD6L7BYDxkVmaqPMNUrjbfSJoKWuo="
80
-
[mod."github.com/blevesearch/vellum"]
81
-
version = "v1.1.0"
82
-
hash = "sha256-GJ1wslEJEZhPbMiANw0W4Dgb1ZouiILbWEaIUfxZTkw="
83
-
[mod."github.com/blevesearch/zapx/v11"]
84
-
version = "v11.4.2"
85
-
hash = "sha256-YzRcc2GwV4VL2Bc+tXOOUL6xNi8LWS76DXEcTkFPTaQ="
86
-
[mod."github.com/blevesearch/zapx/v12"]
87
-
version = "v12.4.2"
88
-
hash = "sha256-yqyzkMWpyXZSF9KLjtiuOmnRUfhaZImk27mU8lsMyJY="
89
-
[mod."github.com/blevesearch/zapx/v13"]
90
-
version = "v13.4.2"
91
-
hash = "sha256-VSS2fI7YUkeGMBH89TB9yW5qG8MWjM6zKbl8DboHsB4="
92
-
[mod."github.com/blevesearch/zapx/v14"]
93
-
version = "v14.4.2"
94
-
hash = "sha256-mAWr+vK0uZWMUaJfGfchzQo4dzMdBbD3Z7F84Jn/ktg="
95
-
[mod."github.com/blevesearch/zapx/v15"]
96
-
version = "v15.4.2"
97
-
hash = "sha256-R8Eh3N4e8CDXiW47J8ZBnfMY1TTnX1SJPwQc4gYChi8="
98
-
[mod."github.com/blevesearch/zapx/v16"]
99
-
version = "v16.2.4"
100
-
hash = "sha256-Jo5k7DflV/ghszOWJTCOGVyyLMvlvSYyxRrmSIFjyEE="
101
38
[mod."github.com/bluekeyes/go-gitdiff"]
102
39
version = "v0.8.2"
103
40
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
104
41
replaced = "tangled.sh/oppi.li/go-gitdiff"
105
42
[mod."github.com/bluesky-social/indigo"]
106
-
version = "v0.0.0-20251003000214-3259b215110e"
107
-
hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo="
43
+
version = "v0.0.0-20250724221105-5827c8fb61bb"
44
+
hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI="
108
45
[mod."github.com/bluesky-social/jetstream"]
109
46
version = "v0.0.0-20241210005130-ea96859b93d1"
110
47
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
111
48
[mod."github.com/bmatcuk/doublestar/v4"]
112
-
version = "v4.9.1"
113
-
hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE="
49
+
version = "v4.7.1"
50
+
hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA="
114
51
[mod."github.com/carlmjohnson/versioninfo"]
115
52
version = "v0.22.5"
116
53
hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw="
···
126
63
[mod."github.com/cespare/xxhash/v2"]
127
64
version = "v2.3.0"
128
65
hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY="
129
-
[mod."github.com/charmbracelet/colorprofile"]
130
-
version = "v0.2.3-0.20250311203215-f60798e515dc"
131
-
hash = "sha256-D9E/bMOyLXAUVOHA1/6o3i+vVmLfwIMOWib6sU7A6+Q="
132
-
[mod."github.com/charmbracelet/lipgloss"]
133
-
version = "v1.1.0"
134
-
hash = "sha256-RHsRT2EZ1nDOElxAK+6/DC9XAaGVjDTgPvRh3pyCfY4="
135
-
[mod."github.com/charmbracelet/log"]
136
-
version = "v0.4.2"
137
-
hash = "sha256-3w1PCM/c4JvVEh2d0sMfv4C77Xs1bPa1Ea84zdynC7I="
138
-
[mod."github.com/charmbracelet/x/ansi"]
139
-
version = "v0.8.0"
140
-
hash = "sha256-/YyDkGrULV2BtnNk3ojeSl0nUWQwIfIdW7WJuGbAZas="
141
-
[mod."github.com/charmbracelet/x/cellbuf"]
142
-
version = "v0.0.13-0.20250311204145-2c3ea96c31dd"
143
-
hash = "sha256-XAhCOt8qJ2vR77lH1ez0IVU1/2CaLTq9jSmrHVg5HHU="
144
-
[mod."github.com/charmbracelet/x/term"]
145
-
version = "v0.2.1"
146
-
hash = "sha256-VBkCZLI90PhMasftGw3403IqoV7d3E5WEGAIVrN5xQM="
147
66
[mod."github.com/cloudflare/circl"]
148
67
version = "v1.6.2-0.20250618153321-aa837fd1539d"
149
68
hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y="
···
226
145
[mod."github.com/go-jose/go-jose/v3"]
227
146
version = "v3.0.4"
228
147
hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ="
229
-
[mod."github.com/go-logfmt/logfmt"]
230
-
version = "v0.6.0"
231
-
hash = "sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg="
232
148
[mod."github.com/go-logr/logr"]
233
149
version = "v1.4.3"
234
150
hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA="
···
247
163
[mod."github.com/gogo/protobuf"]
248
164
version = "v1.3.2"
249
165
hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c="
250
-
[mod."github.com/goki/freetype"]
251
-
version = "v1.0.5"
252
-
hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs="
253
166
[mod."github.com/golang-jwt/jwt/v5"]
254
167
version = "v5.2.3"
255
168
hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo="
···
259
172
[mod."github.com/golang/mock"]
260
173
version = "v1.6.0"
261
174
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
262
-
[mod."github.com/golang/protobuf"]
263
-
version = "v1.5.4"
264
-
hash = "sha256-N3+Lv9lEZjrdOWdQhFj6Y3Iap4rVLEQeI8/eFFyAMZ0="
265
-
[mod."github.com/golang/snappy"]
266
-
version = "v0.0.4"
267
-
hash = "sha256-Umx+5xHAQCN/Gi4HbtMhnDCSPFAXSsjVbXd8n5LhjAA="
268
175
[mod."github.com/google/go-querystring"]
269
176
version = "v1.1.0"
270
177
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
···
361
268
[mod."github.com/ipfs/go-metrics-interface"]
362
269
version = "v0.3.0"
363
270
hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ="
364
-
[mod."github.com/json-iterator/go"]
365
-
version = "v1.1.12"
366
-
hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM="
367
271
[mod."github.com/kevinburke/ssh_config"]
368
272
version = "v1.2.0"
369
273
hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s="
···
391
295
[mod."github.com/lestrrat-go/option"]
392
296
version = "v1.0.1"
393
297
hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI="
394
-
[mod."github.com/lucasb-eyer/go-colorful"]
395
-
version = "v1.2.0"
396
-
hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE="
397
298
[mod."github.com/mattn/go-isatty"]
398
299
version = "v0.0.20"
399
300
hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ="
400
-
[mod."github.com/mattn/go-runewidth"]
401
-
version = "v0.0.16"
402
-
hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ="
403
301
[mod."github.com/mattn/go-sqlite3"]
404
302
version = "v1.14.24"
405
303
hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg="
···
421
319
[mod."github.com/moby/term"]
422
320
version = "v0.5.2"
423
321
hash = "sha256-/G20jUZKx36ktmPU/nEw/gX7kRTl1Dbu7zvNBYNt4xU="
424
-
[mod."github.com/modern-go/concurrent"]
425
-
version = "v0.0.0-20180306012644-bacd9c7ef1dd"
426
-
hash = "sha256-OTySieAgPWR4oJnlohaFTeK1tRaVp/b0d1rYY8xKMzo="
427
-
[mod."github.com/modern-go/reflect2"]
428
-
version = "v1.0.2"
429
-
hash = "sha256-+W9EIW7okXIXjWEgOaMh58eLvBZ7OshW2EhaIpNLSBU="
430
322
[mod."github.com/morikuni/aec"]
431
323
version = "v1.0.0"
432
324
hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE="
433
325
[mod."github.com/mr-tron/base58"]
434
326
version = "v1.2.0"
435
327
hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk="
436
-
[mod."github.com/mschoch/smat"]
437
-
version = "v0.2.0"
438
-
hash = "sha256-DZvUJXjIcta3U+zxzgU3wpoGn/V4lpBY7Xme8aQUi+E="
439
-
[mod."github.com/muesli/termenv"]
440
-
version = "v0.16.0"
441
-
hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI="
442
328
[mod."github.com/multiformats/go-base32"]
443
329
version = "v0.1.0"
444
330
hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio="
···
505
391
[mod."github.com/resend/resend-go/v2"]
506
392
version = "v2.15.0"
507
393
hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg="
508
-
[mod."github.com/rivo/uniseg"]
509
-
version = "v0.4.7"
510
-
hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo="
511
394
[mod."github.com/ryanuber/go-glob"]
512
395
version = "v1.0.0"
513
396
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
···
524
407
[mod."github.com/spaolacci/murmur3"]
525
408
version = "v1.1.0"
526
409
hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M="
527
-
[mod."github.com/srwiley/oksvg"]
528
-
version = "v0.0.0-20221011165216-be6e8873101c"
529
-
hash = "sha256-lZb6Y8HkrDpx9pxS+QQTcXI2MDSSv9pUyVTat59OrSk="
530
-
[mod."github.com/srwiley/rasterx"]
531
-
version = "v0.0.0-20220730225603-2ab79fcdd4ef"
532
-
hash = "sha256-/XmSE/J+f6FLWXGvljh6uBK71uoCAK3h82XQEQ1Ki68="
533
410
[mod."github.com/stretchr/testify"]
534
411
version = "v1.10.0"
535
412
hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI="
···
549
426
version = "v0.3.1"
550
427
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
551
428
[mod."github.com/wyatt915/goldmark-treeblood"]
552
-
version = "v0.0.1"
553
-
hash = "sha256-hAVFaktO02MiiqZFffr8ZlvFEfwxw4Y84OZ2t7e5G7g="
429
+
version = "v0.0.0-20250825231212-5dcbdb2f4b57"
430
+
hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM="
554
431
[mod."github.com/wyatt915/treeblood"]
555
-
version = "v0.1.16"
556
-
hash = "sha256-T68sa+iVx0qY7dDjXEAJvRWQEGXYIpUsf9tcWwO1tIw="
557
-
[mod."github.com/xo/terminfo"]
558
-
version = "v0.0.0-20220910002029-abceb7e1c41e"
559
-
hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU="
432
+
version = "v0.1.15"
433
+
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
560
434
[mod."github.com/yuin/goldmark"]
561
-
version = "v1.7.13"
562
-
hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE="
435
+
version = "v1.7.12"
436
+
hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM="
563
437
[mod."github.com/yuin/goldmark-highlighting/v2"]
564
438
version = "v2.0.0-20230729083705-37449abec8cc"
565
439
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
566
-
[mod."gitlab.com/staticnoise/goldmark-callout"]
567
-
version = "v0.0.0-20240609120641-6366b799e4ab"
568
-
hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44="
569
440
[mod."gitlab.com/yawning/secp256k1-voi"]
570
441
version = "v0.0.0-20230925100816-f2616030848b"
571
442
hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
572
443
[mod."gitlab.com/yawning/tuplehash"]
573
444
version = "v0.0.0-20230713102510-df83abbf9a02"
574
445
hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato="
575
-
[mod."go.etcd.io/bbolt"]
576
-
version = "v1.4.0"
577
-
hash = "sha256-nR/YGQjwz6ue99IFbgw/01Pl8PhoOjpKiwVy5sJxlps="
578
446
[mod."go.opentelemetry.io/auto/sdk"]
579
447
version = "v1.1.0"
580
448
hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
···
611
479
[mod."golang.org/x/exp"]
612
480
version = "v0.0.0-20250620022241-b7579e27df2b"
613
481
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
614
-
[mod."golang.org/x/image"]
615
-
version = "v0.31.0"
616
-
hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg="
617
482
[mod."golang.org/x/net"]
618
483
version = "v0.42.0"
619
484
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
620
485
[mod."golang.org/x/sync"]
621
-
version = "v0.17.0"
622
-
hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0="
486
+
version = "v0.16.0"
487
+
hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks="
623
488
[mod."golang.org/x/sys"]
624
489
version = "v0.34.0"
625
490
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
626
491
[mod."golang.org/x/text"]
627
-
version = "v0.29.0"
628
-
hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI="
492
+
version = "v0.27.0"
493
+
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
629
494
[mod."golang.org/x/time"]
630
495
version = "v0.12.0"
631
496
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
···
662
527
[mod."lukechampine.com/blake3"]
663
528
version = "v1.4.1"
664
529
hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
530
+
[mod."tangled.sh/icyphox.sh/atproto-oauth"]
531
+
version = "v0.0.0-20250724194903-28e660378cb1"
532
+
hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+18
-285
nix/modules/appview.nix
+18
-285
nix/modules/appview.nix
···
3
3
lib,
4
4
...
5
5
}: let
6
-
cfg = config.services.tangled.appview;
6
+
cfg = config.services.tangled-appview;
7
7
in
8
8
with lib; {
9
9
options = {
10
-
services.tangled.appview = {
10
+
services.tangled-appview = {
11
11
enable = mkOption {
12
12
type = types.bool;
13
13
default = false;
14
14
description = "Enable tangled appview";
15
15
};
16
-
17
16
package = mkOption {
18
17
type = types.package;
19
18
description = "Package to use for the appview";
20
19
};
21
-
22
-
# core configuration
23
20
port = mkOption {
24
-
type = types.port;
21
+
type = types.int;
25
22
default = 3000;
26
23
description = "Port to run the appview on";
27
24
};
28
-
29
-
listenAddr = mkOption {
30
-
type = types.str;
31
-
default = "0.0.0.0:${toString cfg.port}";
32
-
description = "Listen address for the appview service";
33
-
};
34
-
35
-
dbPath = mkOption {
36
-
type = types.str;
37
-
default = "/var/lib/appview/appview.db";
38
-
description = "Path to the SQLite database file";
39
-
};
40
-
41
-
appviewHost = mkOption {
25
+
cookie_secret = mkOption {
42
26
type = types.str;
43
-
default = "https://tangled.org";
44
-
example = "https://example.com";
45
-
description = "Public host URL for the appview instance";
46
-
};
47
-
48
-
appviewName = mkOption {
49
-
type = types.str;
50
-
default = "Tangled";
51
-
description = "Display name for the appview instance";
52
-
};
53
-
54
-
dev = mkOption {
55
-
type = types.bool;
56
-
default = false;
57
-
description = "Enable development mode";
58
-
};
59
-
60
-
disallowedNicknamesFile = mkOption {
61
-
type = types.nullOr types.path;
62
-
default = null;
63
-
description = "Path to file containing disallowed nicknames";
64
-
};
65
-
66
-
# redis configuration
67
-
redis = {
68
-
addr = mkOption {
69
-
type = types.str;
70
-
default = "localhost:6379";
71
-
description = "Redis server address";
72
-
};
73
-
74
-
db = mkOption {
75
-
type = types.int;
76
-
default = 0;
77
-
description = "Redis database number";
78
-
};
79
-
};
80
-
81
-
# jetstream configuration
82
-
jetstream = {
83
-
endpoint = mkOption {
84
-
type = types.str;
85
-
default = "wss://jetstream1.us-east.bsky.network/subscribe";
86
-
description = "Jetstream WebSocket endpoint";
87
-
};
88
-
};
89
-
90
-
# knotstream consumer configuration
91
-
knotstream = {
92
-
retryInterval = mkOption {
93
-
type = types.str;
94
-
default = "60s";
95
-
description = "Initial retry interval for knotstream consumer";
96
-
};
97
-
98
-
maxRetryInterval = mkOption {
99
-
type = types.str;
100
-
default = "120m";
101
-
description = "Maximum retry interval for knotstream consumer";
102
-
};
103
-
104
-
connectionTimeout = mkOption {
105
-
type = types.str;
106
-
default = "5s";
107
-
description = "Connection timeout for knotstream consumer";
108
-
};
109
-
110
-
workerCount = mkOption {
111
-
type = types.int;
112
-
default = 64;
113
-
description = "Number of workers for knotstream consumer";
114
-
};
115
-
116
-
queueSize = mkOption {
117
-
type = types.int;
118
-
default = 100;
119
-
description = "Queue size for knotstream consumer";
120
-
};
121
-
};
122
-
123
-
# spindlestream consumer configuration
124
-
spindlestream = {
125
-
retryInterval = mkOption {
126
-
type = types.str;
127
-
default = "60s";
128
-
description = "Initial retry interval for spindlestream consumer";
129
-
};
130
-
131
-
maxRetryInterval = mkOption {
132
-
type = types.str;
133
-
default = "120m";
134
-
description = "Maximum retry interval for spindlestream consumer";
135
-
};
136
-
137
-
connectionTimeout = mkOption {
138
-
type = types.str;
139
-
default = "5s";
140
-
description = "Connection timeout for spindlestream consumer";
141
-
};
142
-
143
-
workerCount = mkOption {
144
-
type = types.int;
145
-
default = 64;
146
-
description = "Number of workers for spindlestream consumer";
147
-
};
148
-
149
-
queueSize = mkOption {
150
-
type = types.int;
151
-
default = 100;
152
-
description = "Queue size for spindlestream consumer";
153
-
};
27
+
default = "00000000000000000000000000000000";
28
+
description = "Cookie secret";
154
29
};
155
-
156
-
# resend configuration
157
-
resend = {
158
-
sentFrom = mkOption {
159
-
type = types.str;
160
-
default = "noreply@notifs.tangled.sh";
161
-
description = "Email address to send notifications from";
162
-
};
163
-
};
164
-
165
-
# posthog configuration
166
-
posthog = {
167
-
endpoint = mkOption {
168
-
type = types.str;
169
-
default = "https://eu.i.posthog.com";
170
-
description = "PostHog API endpoint";
171
-
};
172
-
};
173
-
174
-
# camo configuration
175
-
camo = {
176
-
host = mkOption {
177
-
type = types.str;
178
-
default = "https://camo.tangled.sh";
179
-
description = "Camo proxy host URL";
180
-
};
181
-
};
182
-
183
-
# avatar configuration
184
-
avatar = {
185
-
host = mkOption {
186
-
type = types.str;
187
-
default = "https://avatar.tangled.sh";
188
-
description = "Avatar service host URL";
189
-
};
190
-
};
191
-
192
-
plc = {
193
-
url = mkOption {
194
-
type = types.str;
195
-
default = "https://plc.directory";
196
-
description = "PLC directory URL";
197
-
};
198
-
};
199
-
200
-
pds = {
201
-
host = mkOption {
202
-
type = types.str;
203
-
default = "https://tngl.sh";
204
-
description = "PDS host URL";
205
-
};
206
-
};
207
-
208
-
label = {
209
-
defaults = mkOption {
210
-
type = types.listOf types.str;
211
-
default = [
212
-
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix"
213
-
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"
214
-
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate"
215
-
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation"
216
-
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"
217
-
];
218
-
description = "Default label definitions";
219
-
};
220
-
221
-
goodFirstIssue = mkOption {
222
-
type = types.str;
223
-
default = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue";
224
-
description = "Good first issue label definition";
225
-
};
226
-
};
227
-
228
30
environmentFile = mkOption {
229
31
type = with types; nullOr path;
230
32
default = null;
231
-
example = "/etc/appview.env";
33
+
example = "/etc/tangled-appview.env";
232
34
description = ''
233
35
Additional environment file as defined in {manpage}`systemd.exec(5)`.
234
36
235
-
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET`,
236
-
{env}`TANGLED_OAUTH_CLIENT_SECRET`, {env}`TANGLED_RESEND_API_KEY`,
237
-
{env}`TANGLED_CAMO_SHARED_SECRET`, {env}`TANGLED_AVATAR_SHARED_SECRET`,
238
-
{env}`TANGLED_REDIS_PASS`, {env}`TANGLED_PDS_ADMIN_SECRET`,
239
-
{env}`TANGLED_CLOUDFLARE_API_TOKEN`, {env}`TANGLED_CLOUDFLARE_ZONE_ID`,
240
-
{env}`TANGLED_CLOUDFLARE_TURNSTILE_SITE_KEY`,
241
-
{env}`TANGLED_CLOUDFLARE_TURNSTILE_SECRET_KEY`,
242
-
{env}`TANGLED_POSTHOG_API_KEY`, {env}`TANGLED_APP_PASSWORD`,
243
-
and {env}`TANGLED_ALT_APP_PASSWORD` may be passed to the service
244
-
without making them world readable in the nix store.
37
+
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
38
+
passed to the service without makeing them world readable in the
39
+
nix store.
40
+
245
41
'';
246
42
};
247
43
};
248
44
};
249
45
250
46
config = mkIf cfg.enable {
251
-
services.redis.servers.appview = {
252
-
enable = true;
253
-
port = 6379;
254
-
};
255
-
256
-
systemd.services.appview = {
47
+
systemd.services.tangled-appview = {
257
48
description = "tangled appview service";
258
49
wantedBy = ["multi-user.target"];
259
-
after = ["redis-appview.service" "network-online.target"];
260
-
requires = ["redis-appview.service"];
261
-
wants = ["network-online.target"];
262
50
263
51
serviceConfig = {
264
-
Type = "simple";
52
+
ListenStream = "0.0.0.0:${toString cfg.port}";
265
53
ExecStart = "${cfg.package}/bin/appview";
266
54
Restart = "always";
267
-
RestartSec = "10s";
268
-
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
269
-
270
-
# state directory
271
-
StateDirectory = "appview";
272
-
WorkingDirectory = "/var/lib/appview";
273
-
274
-
# security hardening
275
-
NoNewPrivileges = true;
276
-
PrivateTmp = true;
277
-
ProtectSystem = "strict";
278
-
ProtectHome = true;
279
-
ReadWritePaths = ["/var/lib/appview"];
55
+
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
280
56
};
281
57
282
-
environment =
283
-
{
284
-
TANGLED_DB_PATH = cfg.dbPath;
285
-
TANGLED_LISTEN_ADDR = cfg.listenAddr;
286
-
TANGLED_APPVIEW_HOST = cfg.appviewHost;
287
-
TANGLED_APPVIEW_NAME = cfg.appviewName;
288
-
TANGLED_DEV =
289
-
if cfg.dev
290
-
then "true"
291
-
else "false";
292
-
}
293
-
// optionalAttrs (cfg.disallowedNicknamesFile != null) {
294
-
TANGLED_DISALLOWED_NICKNAMES_FILE = cfg.disallowedNicknamesFile;
295
-
}
296
-
// {
297
-
TANGLED_REDIS_ADDR = cfg.redis.addr;
298
-
TANGLED_REDIS_DB = toString cfg.redis.db;
299
-
300
-
TANGLED_JETSTREAM_ENDPOINT = cfg.jetstream.endpoint;
301
-
302
-
TANGLED_KNOTSTREAM_RETRY_INTERVAL = cfg.knotstream.retryInterval;
303
-
TANGLED_KNOTSTREAM_MAX_RETRY_INTERVAL = cfg.knotstream.maxRetryInterval;
304
-
TANGLED_KNOTSTREAM_CONNECTION_TIMEOUT = cfg.knotstream.connectionTimeout;
305
-
TANGLED_KNOTSTREAM_WORKER_COUNT = toString cfg.knotstream.workerCount;
306
-
TANGLED_KNOTSTREAM_QUEUE_SIZE = toString cfg.knotstream.queueSize;
307
-
308
-
TANGLED_SPINDLESTREAM_RETRY_INTERVAL = cfg.spindlestream.retryInterval;
309
-
TANGLED_SPINDLESTREAM_MAX_RETRY_INTERVAL = cfg.spindlestream.maxRetryInterval;
310
-
TANGLED_SPINDLESTREAM_CONNECTION_TIMEOUT = cfg.spindlestream.connectionTimeout;
311
-
TANGLED_SPINDLESTREAM_WORKER_COUNT = toString cfg.spindlestream.workerCount;
312
-
TANGLED_SPINDLESTREAM_QUEUE_SIZE = toString cfg.spindlestream.queueSize;
313
-
314
-
TANGLED_RESEND_SENT_FROM = cfg.resend.sentFrom;
315
-
316
-
TANGLED_POSTHOG_ENDPOINT = cfg.posthog.endpoint;
317
-
318
-
TANGLED_CAMO_HOST = cfg.camo.host;
319
-
320
-
TANGLED_AVATAR_HOST = cfg.avatar.host;
321
-
322
-
TANGLED_PLC_URL = cfg.plc.url;
323
-
324
-
TANGLED_PDS_HOST = cfg.pds.host;
325
-
326
-
TANGLED_LABEL_DEFAULTS = concatStringsSep "," cfg.label.defaults;
327
-
TANGLED_LABEL_GFI = cfg.label.goodFirstIssue;
328
-
};
58
+
environment = {
59
+
TANGLED_DB_PATH = "appview.db";
60
+
TANGLED_COOKIE_SECRET = cfg.cookie_secret;
61
+
};
329
62
};
330
63
};
331
64
}
+6
-76
nix/modules/knot.nix
+6
-76
nix/modules/knot.nix
···
4
4
lib,
5
5
...
6
6
}: let
7
-
cfg = config.services.tangled.knot;
7
+
cfg = config.services.tangled-knot;
8
8
in
9
9
with lib; {
10
10
options = {
11
-
services.tangled.knot = {
11
+
services.tangled-knot = {
12
12
enable = mkOption {
13
13
type = types.bool;
14
14
default = false;
···
22
22
23
23
appviewEndpoint = mkOption {
24
24
type = types.str;
25
-
default = "https://tangled.org";
25
+
default = "https://tangled.sh";
26
26
description = "Appview endpoint";
27
27
};
28
28
···
51
51
description = "Path where repositories are scanned from";
52
52
};
53
53
54
-
readme = mkOption {
55
-
type = types.listOf types.str;
56
-
default = [
57
-
"README.md"
58
-
"readme.md"
59
-
"README"
60
-
"readme"
61
-
"README.markdown"
62
-
"readme.markdown"
63
-
"README.txt"
64
-
"readme.txt"
65
-
"README.rst"
66
-
"readme.rst"
67
-
"README.org"
68
-
"readme.org"
69
-
"README.asciidoc"
70
-
"readme.asciidoc"
71
-
];
72
-
description = "List of README filenames to look for (in priority order)";
73
-
};
74
-
75
54
mainBranch = mkOption {
76
55
type = types.str;
77
56
default = "main";
78
57
description = "Default branch name for repositories";
79
-
};
80
-
};
81
-
82
-
git = {
83
-
userName = mkOption {
84
-
type = types.str;
85
-
default = "Tangled";
86
-
description = "Git user name used as committer";
87
-
};
88
-
89
-
userEmail = mkOption {
90
-
type = types.str;
91
-
default = "noreply@tangled.org";
92
-
description = "Git user email used as committer";
93
58
};
94
59
};
95
60
···
142
107
143
108
hostname = mkOption {
144
109
type = types.str;
145
-
example = "my.knot.com";
110
+
example = "knot.tangled.sh";
146
111
description = "Hostname for the server (required)";
147
-
};
148
-
149
-
plcUrl = mkOption {
150
-
type = types.str;
151
-
default = "https://plc.directory";
152
-
description = "atproto PLC directory";
153
-
};
154
-
155
-
jetstreamEndpoint = mkOption {
156
-
type = types.str;
157
-
default = "wss://jetstream1.us-west.bsky.network/subscribe";
158
-
description = "Jetstream endpoint to subscribe to";
159
-
};
160
-
161
-
logDids = mkOption {
162
-
type = types.bool;
163
-
default = true;
164
-
description = "Enable logging of DIDs";
165
112
};
166
113
167
114
dev = mkOption {
···
231
178
mkdir -p "${cfg.stateDir}/.config/git"
232
179
cat > "${cfg.stateDir}/.config/git/config" << EOF
233
180
[user]
234
-
name = ${cfg.git.userName}
235
-
email = ${cfg.git.userEmail}
181
+
name = Git User
182
+
email = git@example.com
236
183
[receive]
237
184
advertisePushOptions = true
238
-
[uploadpack]
239
-
allowFilter = true
240
185
EOF
241
186
${setMotd}
242
187
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
···
248
193
WorkingDirectory = cfg.stateDir;
249
194
Environment = [
250
195
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
251
-
"KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}"
252
196
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
253
-
"KNOT_GIT_USER_NAME=${cfg.git.userName}"
254
-
"KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}"
255
197
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
256
198
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
257
199
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
258
200
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
259
201
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
260
-
"KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}"
261
-
"KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
262
202
"KNOT_SERVER_OWNER=${cfg.server.owner}"
263
-
"KNOT_SERVER_LOG_DIDS=${
264
-
if cfg.server.logDids
265
-
then "true"
266
-
else "false"
267
-
}"
268
-
"KNOT_SERVER_DEV=${
269
-
if cfg.server.dev
270
-
then "true"
271
-
else "false"
272
-
}"
273
203
];
274
204
ExecStart = "${cfg.package}/bin/knot server";
275
205
Restart = "always";
+5
-12
nix/modules/spindle.nix
+5
-12
nix/modules/spindle.nix
···
3
3
lib,
4
4
...
5
5
}: let
6
-
cfg = config.services.tangled.spindle;
6
+
cfg = config.services.tangled-spindle;
7
7
in
8
8
with lib; {
9
9
options = {
10
-
services.tangled.spindle = {
10
+
services.tangled-spindle = {
11
11
enable = mkOption {
12
12
type = types.bool;
13
13
default = false;
···
33
33
34
34
hostname = mkOption {
35
35
type = types.str;
36
-
example = "my.spindle.com";
36
+
example = "spindle.tangled.sh";
37
37
description = "Hostname for the server (required)";
38
38
};
39
39
40
-
plcUrl = mkOption {
41
-
type = types.str;
42
-
default = "https://plc.directory";
43
-
description = "atproto PLC directory";
44
-
};
45
-
46
40
jetstreamEndpoint = mkOption {
47
41
type = types.str;
48
42
default = "wss://jetstream1.us-west.bsky.network/subscribe";
···
98
92
pipelines = {
99
93
nixery = mkOption {
100
94
type = types.str;
101
-
default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet
95
+
default = "nixery.tangled.sh";
102
96
description = "Nixery instance to use";
103
97
};
104
98
···
125
119
"SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
126
120
"SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}"
127
121
"SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
128
-
"SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}"
129
-
"SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
122
+
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
130
123
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
131
124
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
132
125
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
-3
nix/pkgs/appview-static-files.nix
-3
nix/pkgs/appview-static-files.nix
···
5
5
lucide-src,
6
6
inter-fonts-src,
7
7
ibm-plex-mono-src,
8
-
actor-typeahead-src,
9
8
sqlite-lib,
10
9
tailwindcss,
11
10
src,
···
23
22
cp -rf ${lucide-src}/*.svg icons/
24
23
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
25
24
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
26
-
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
27
25
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
28
-
cp -f ${actor-typeahead-src}/actor-typeahead.js .
29
26
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
30
27
# for whatever reason (produces broken css), so we are doing this instead
31
28
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+18
nix/pkgs/genjwks.nix
+18
nix/pkgs/genjwks.nix
···
1
+
{
2
+
buildGoApplication,
3
+
modules,
4
+
}:
5
+
buildGoApplication {
6
+
pname = "genjwks";
7
+
version = "0.1.0";
8
+
src = ../../cmd/genjwks;
9
+
postPatch = ''
10
+
ln -s ${../../go.mod} ./go.mod
11
+
'';
12
+
postInstall = ''
13
+
mv $out/bin/core $out/bin/genjwks
14
+
'';
15
+
inherit modules;
16
+
doCheck = false;
17
+
CGO_ENABLED = 0;
18
+
}
-12
nix/pkgs/goat.nix
-12
nix/pkgs/goat.nix
+1
-1
nix/pkgs/knot-unwrapped.nix
+1
-1
nix/pkgs/knot-unwrapped.nix
+8
-21
nix/vm.nix
+8
-21
nix/vm.nix
···
10
10
if var == ""
11
11
then throw "\$${name} must be defined, see docs/hacking.md for more details"
12
12
else var;
13
-
envVarOr = name: default: let
14
-
var = builtins.getEnv name;
15
-
in
16
-
if var != ""
17
-
then var
18
-
else default;
19
-
20
-
plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory";
21
-
jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe";
22
13
in
23
14
nixpkgs.lib.nixosSystem {
24
15
inherit system;
···
82
73
time.timeZone = "Europe/London";
83
74
services.getty.autologinUser = "root";
84
75
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
85
-
services.tangled.knot = {
76
+
services.tangled-knot = {
86
77
enable = true;
87
78
motd = "Welcome to the development knot!\n";
88
79
server = {
89
80
owner = envVar "TANGLED_VM_KNOT_OWNER";
90
-
hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6000";
91
-
plcUrl = plcUrl;
92
-
jetstreamEndpoint = jetstream;
81
+
hostname = "localhost:6000";
93
82
listenAddr = "0.0.0.0:6000";
94
83
};
95
84
};
96
-
services.tangled.spindle = {
85
+
services.tangled-spindle = {
97
86
enable = true;
98
87
server = {
99
88
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
100
-
hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
101
-
plcUrl = plcUrl;
102
-
jetstreamEndpoint = jetstream;
89
+
hostname = "localhost:6555";
103
90
listenAddr = "0.0.0.0:6555";
104
91
dev = true;
105
92
queueSize = 100;
···
112
99
users = {
113
100
# So we don't have to deal with permission clashing between
114
101
# blank disk VMs and existing state
115
-
users.${config.services.tangled.knot.gitUser}.uid = 666;
116
-
groups.${config.services.tangled.knot.gitUser}.gid = 666;
102
+
users.${config.services.tangled-knot.gitUser}.uid = 666;
103
+
groups.${config.services.tangled-knot.gitUser}.gid = 666;
117
104
118
105
# TODO: separate spindle user
119
106
};
···
133
120
serviceConfig.PermissionsStartOnly = true;
134
121
};
135
122
in {
136
-
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir;
137
-
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath);
123
+
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir;
124
+
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath);
138
125
};
139
126
})
140
127
];
+7
-18
patchutil/patchutil.go
+7
-18
patchutil/patchutil.go
···
1
1
package patchutil
2
2
3
3
import (
4
-
"errors"
5
4
"fmt"
6
5
"log"
7
6
"os"
···
43
42
// IsPatchValid checks if the given patch string is valid.
44
43
// It performs very basic sniffing for either git-diff or git-format-patch
45
44
// header lines. For format patches, it attempts to extract and validate each one.
46
-
var (
47
-
EmptyPatchError error = errors.New("patch is empty")
48
-
GenericPatchError error = errors.New("patch is invalid")
49
-
FormatPatchError error = errors.New("patch is not a valid format-patch")
50
-
)
51
-
52
-
func IsPatchValid(patch string) error {
45
+
func IsPatchValid(patch string) bool {
53
46
if len(patch) == 0 {
54
-
return EmptyPatchError
47
+
return false
55
48
}
56
49
57
50
lines := strings.Split(patch, "\n")
58
51
if len(lines) < 2 {
59
-
return EmptyPatchError
52
+
return false
60
53
}
61
54
62
55
firstLine := strings.TrimSpace(lines[0])
···
67
60
strings.HasPrefix(firstLine, "Index: ") ||
68
61
strings.HasPrefix(firstLine, "+++ ") ||
69
62
strings.HasPrefix(firstLine, "@@ ") {
70
-
return nil
63
+
return true
71
64
}
72
65
73
66
// check if it's format-patch
···
77
70
// it's safe to say it's broken.
78
71
patches, err := ExtractPatches(patch)
79
72
if err != nil {
80
-
return fmt.Errorf("%w: %w", FormatPatchError, err)
81
-
}
82
-
if len(patches) == 0 {
83
-
return EmptyPatchError
73
+
return false
84
74
}
85
-
86
-
return nil
75
+
return len(patches) > 0
87
76
}
88
77
89
-
return GenericPatchError
78
+
return false
90
79
}
91
80
92
81
func IsFormatPatch(patch string) bool {
+12
-13
patchutil/patchutil_test.go
+12
-13
patchutil/patchutil_test.go
···
1
1
package patchutil
2
2
3
3
import (
4
-
"errors"
5
4
"reflect"
6
5
"testing"
7
6
)
···
10
9
tests := []struct {
11
10
name string
12
11
patch string
13
-
expected error
12
+
expected bool
14
13
}{
15
14
{
16
15
name: `empty patch`,
17
16
patch: ``,
18
-
expected: EmptyPatchError,
17
+
expected: false,
19
18
},
20
19
{
21
20
name: `single line patch`,
22
21
patch: `single line`,
23
-
expected: EmptyPatchError,
22
+
expected: false,
24
23
},
25
24
{
26
25
name: `valid diff patch`,
···
32
31
-old line
33
32
+new line
34
33
context`,
35
-
expected: nil,
34
+
expected: true,
36
35
},
37
36
{
38
37
name: `valid patch starting with ---`,
···
42
41
-old line
43
42
+new line
44
43
context`,
45
-
expected: nil,
44
+
expected: true,
46
45
},
47
46
{
48
47
name: `valid patch starting with Index`,
···
54
53
-old line
55
54
+new line
56
55
context`,
57
-
expected: nil,
56
+
expected: true,
58
57
},
59
58
{
60
59
name: `valid patch starting with +++`,
···
64
63
-old line
65
64
+new line
66
65
context`,
67
-
expected: nil,
66
+
expected: true,
68
67
},
69
68
{
70
69
name: `valid patch starting with @@`,
···
73
72
+new line
74
73
context
75
74
`,
76
-
expected: nil,
75
+
expected: true,
77
76
},
78
77
{
79
78
name: `valid format patch`,
···
91
90
+new content
92
91
--
93
92
2.48.1`,
94
-
expected: nil,
93
+
expected: true,
95
94
},
96
95
{
97
96
name: `invalid format patch`,
98
97
patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001
99
98
From: Author <author@example.com>
100
99
This is not a valid patch format`,
101
-
expected: FormatPatchError,
100
+
expected: false,
102
101
},
103
102
{
104
103
name: `not a patch at all`,
···
106
105
just some
107
106
random text
108
107
that isn't a patch`,
109
-
expected: GenericPatchError,
108
+
expected: false,
110
109
},
111
110
}
112
111
113
112
for _, tt := range tests {
114
113
t.Run(tt.name, func(t *testing.T) {
115
114
result := IsPatchValid(tt.patch)
116
-
if !errors.Is(result, tt.expected) {
115
+
if result != tt.expected {
117
116
t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected)
118
117
}
119
118
})
+26
scripts/appview.sh
+26
scripts/appview.sh
···
1
+
#!/bin/bash
2
+
3
+
# Variables
4
+
BINARY_NAME="appview"
5
+
BINARY_PATH=".bin/app"
6
+
SERVER="95.111.206.63"
7
+
USER="appview"
8
+
9
+
# SCP the binary to root's home directory
10
+
scp "$BINARY_PATH" root@$SERVER:/root/"$BINARY_NAME"
11
+
12
+
# SSH into the server and perform the necessary operations
13
+
ssh root@$SERVER <<EOF
14
+
set -e # Exit on error
15
+
16
+
# Move binary to /usr/local/bin and set executable permissions
17
+
mv /root/$BINARY_NAME /usr/local/bin/$BINARY_NAME
18
+
chmod +x /usr/local/bin/$BINARY_NAME
19
+
20
+
su appview
21
+
cd ~
22
+
./reset.sh
23
+
EOF
24
+
25
+
echo "Deployment complete."
26
+
+5
scripts/generate-jwks.sh
+5
scripts/generate-jwks.sh
-1
spindle/config/config.go
-1
spindle/config/config.go
···
13
13
DBPath string `env:"DB_PATH, default=spindle.db"`
14
14
Hostname string `env:"HOSTNAME, required"`
15
15
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
16
-
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
17
16
Dev bool `env:"DEV, default=false"`
18
17
Owner string `env:"OWNER, required"`
19
18
Secrets Secrets `env:",prefix=SECRETS_"`
+3
-13
spindle/engine/engine.go
+3
-13
spindle/engine/engine.go
···
79
79
defer cancel()
80
80
81
81
for stepIdx, step := range w.Steps {
82
-
// log start of step
83
82
if wfLogger != nil {
84
-
wfLogger.
85
-
ControlWriter(stepIdx, step, models.StepStatusStart).
86
-
Write([]byte{0})
83
+
ctl := wfLogger.ControlWriter(stepIdx, step)
84
+
ctl.Write([]byte(step.Name()))
87
85
}
88
86
89
87
err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger)
90
-
91
-
// log end of step
92
-
if wfLogger != nil {
93
-
wfLogger.
94
-
ControlWriter(stepIdx, step, models.StepStatusEnd).
95
-
Write([]byte{0})
96
-
}
97
-
98
88
if err != nil {
99
89
if errors.Is(err, ErrTimedOut) {
100
90
dbErr := db.StatusTimeout(wid, n)
···
125
115
if err := eg.Wait(); err != nil {
126
116
l.Error("failed to run one or more workflows", "err", err)
127
117
} else {
128
-
l.Info("successfully ran full pipeline")
118
+
l.Error("successfully ran full pipeline")
129
119
}
130
120
}
+3
-3
spindle/engines/nixery/engine.go
+3
-3
spindle/engines/nixery/engine.go
···
222
222
},
223
223
ReadonlyRootfs: false,
224
224
CapDrop: []string{"ALL"},
225
-
CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"},
225
+
CapAdd: []string{"CAP_DAC_OVERRIDE"},
226
226
SecurityOpt: []string{"no-new-privileges"},
227
227
ExtraHosts: []string{"host.docker.internal:host-gateway"},
228
228
}, nil, nil, "")
···
381
381
defer logs.Close()
382
382
383
383
_, err = stdcopy.StdCopy(
384
-
wfLogger.DataWriter(stepIdx, "stdout"),
385
-
wfLogger.DataWriter(stepIdx, "stderr"),
384
+
wfLogger.DataWriter("stdout"),
385
+
wfLogger.DataWriter("stderr"),
386
386
logs.Reader,
387
387
)
388
388
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
+7
-3
spindle/ingester.go
+7
-3
spindle/ingester.go
···
9
9
10
10
"tangled.org/core/api/tangled"
11
11
"tangled.org/core/eventconsumer"
12
+
"tangled.org/core/idresolver"
12
13
"tangled.org/core/rbac"
13
14
"tangled.org/core/spindle/db"
14
15
···
141
142
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
142
143
var err error
143
144
did := e.Did
145
+
resolver := idresolver.DefaultResolver()
144
146
145
147
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
146
148
···
188
190
}
189
191
190
192
// add collaborators to rbac
191
-
owner, err := s.res.ResolveIdent(ctx, did)
193
+
owner, err := resolver.ResolveIdent(ctx, did)
192
194
if err != nil || owner.Handle.IsInvalidHandle() {
193
195
return err
194
196
}
···
223
225
return err
224
226
}
225
227
226
-
subjectId, err := s.res.ResolveIdent(ctx, record.Subject)
228
+
resolver := idresolver.DefaultResolver()
229
+
230
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
227
231
if err != nil || subjectId.Handle.IsInvalidHandle() {
228
232
return err
229
233
}
···
236
240
237
241
// TODO: get rid of this entirely
238
242
// resolve this aturi to extract the repo record
239
-
owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String())
243
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
240
244
if err != nil || owner.Handle.IsInvalidHandle() {
241
245
return fmt.Errorf("failed to resolve handle: %w", err)
242
246
}
-35
spindle/middleware.go
-35
spindle/middleware.go
···
1
-
package spindle
2
-
3
-
import (
4
-
"log/slog"
5
-
"net/http"
6
-
"time"
7
-
)
8
-
9
-
func (s *Spindle) RequestLogger(next http.Handler) http.Handler {
10
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
11
-
start := time.Now()
12
-
13
-
next.ServeHTTP(w, r)
14
-
15
-
// Build query params as slog.Attrs for the group
16
-
queryParams := r.URL.Query()
17
-
queryAttrs := make([]any, 0, len(queryParams))
18
-
for key, values := range queryParams {
19
-
if len(values) == 1 {
20
-
queryAttrs = append(queryAttrs, slog.String(key, values[0]))
21
-
} else {
22
-
queryAttrs = append(queryAttrs, slog.Any(key, values))
23
-
}
24
-
}
25
-
26
-
s.l.LogAttrs(r.Context(), slog.LevelInfo, "",
27
-
slog.Group("request",
28
-
slog.String("method", r.Method),
29
-
slog.String("path", r.URL.Path),
30
-
slog.Group("query", queryAttrs...),
31
-
slog.Duration("duration", time.Since(start)),
32
-
),
33
-
)
34
-
})
35
-
}
+11
-14
spindle/models/logger.go
+11
-14
spindle/models/logger.go
···
37
37
return l.file.Close()
38
38
}
39
39
40
-
func (l *WorkflowLogger) DataWriter(idx int, stream string) io.Writer {
40
+
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
41
+
// TODO: emit stream
41
42
return &dataWriter{
42
43
logger: l,
43
-
idx: idx,
44
44
stream: stream,
45
45
}
46
46
}
47
47
48
-
func (l *WorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer {
48
+
func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer {
49
49
return &controlWriter{
50
-
logger: l,
51
-
idx: idx,
52
-
step: step,
53
-
stepStatus: stepStatus,
50
+
logger: l,
51
+
idx: idx,
52
+
step: step,
54
53
}
55
54
}
56
55
57
56
type dataWriter struct {
58
57
logger *WorkflowLogger
59
-
idx int
60
58
stream string
61
59
}
62
60
63
61
func (w *dataWriter) Write(p []byte) (int, error) {
64
62
line := strings.TrimRight(string(p), "\r\n")
65
-
entry := NewDataLogLine(w.idx, line, w.stream)
63
+
entry := NewDataLogLine(line, w.stream)
66
64
if err := w.logger.encoder.Encode(entry); err != nil {
67
65
return 0, err
68
66
}
···
70
68
}
71
69
72
70
type controlWriter struct {
73
-
logger *WorkflowLogger
74
-
idx int
75
-
step Step
76
-
stepStatus StepStatus
71
+
logger *WorkflowLogger
72
+
idx int
73
+
step Step
77
74
}
78
75
79
76
func (w *controlWriter) Write(_ []byte) (int, error) {
80
-
entry := NewControlLogLine(w.idx, w.step, w.stepStatus)
77
+
entry := NewControlLogLine(w.idx, w.step)
81
78
if err := w.logger.encoder.Encode(entry); err != nil {
82
79
return 0, err
83
80
}
+8
-23
spindle/models/models.go
+8
-23
spindle/models/models.go
···
4
4
"fmt"
5
5
"regexp"
6
6
"slices"
7
-
"time"
8
7
9
8
"tangled.org/core/api/tangled"
10
9
···
77
76
var (
78
77
// step log data
79
78
LogKindData LogKind = "data"
80
-
// indicates status of a step
79
+
// indicates start/end of a step
81
80
LogKindControl LogKind = "control"
82
81
)
83
82
84
-
// step status indicator in control log lines
85
-
type StepStatus string
86
-
87
-
var (
88
-
StepStatusStart StepStatus = "start"
89
-
StepStatusEnd StepStatus = "end"
90
-
)
91
-
92
83
type LogLine struct {
93
-
Kind LogKind `json:"kind"`
94
-
Content string `json:"content"`
95
-
Time time.Time `json:"time"`
96
-
StepId int `json:"step_id"`
84
+
Kind LogKind `json:"kind"`
85
+
Content string `json:"content"`
97
86
98
87
// fields if kind is "data"
99
88
Stream string `json:"stream,omitempty"`
100
89
101
90
// fields if kind is "control"
102
-
StepStatus StepStatus `json:"step_status,omitempty"`
103
-
StepKind StepKind `json:"step_kind,omitempty"`
104
-
StepCommand string `json:"step_command,omitempty"`
91
+
StepId int `json:"step_id,omitempty"`
92
+
StepKind StepKind `json:"step_kind,omitempty"`
93
+
StepCommand string `json:"step_command,omitempty"`
105
94
}
106
95
107
-
func NewDataLogLine(idx int, content, stream string) LogLine {
96
+
func NewDataLogLine(content, stream string) LogLine {
108
97
return LogLine{
109
98
Kind: LogKindData,
110
-
Time: time.Now(),
111
99
Content: content,
112
-
StepId: idx,
113
100
Stream: stream,
114
101
}
115
102
}
116
103
117
-
func NewControlLogLine(idx int, step Step, status StepStatus) LogLine {
104
+
func NewControlLogLine(idx int, step Step) LogLine {
118
105
return LogLine{
119
106
Kind: LogKindControl,
120
-
Time: time.Now(),
121
107
Content: step.Name(),
122
108
StepId: idx,
123
-
StepStatus: status,
124
109
StepKind: step.Kind(),
125
110
StepCommand: step.Command(),
126
111
}
+47
-92
spindle/server.go
+47
-92
spindle/server.go
···
49
49
vault secrets.Manager
50
50
}
51
51
52
-
// New creates a new Spindle server with the provided configuration and engines.
53
-
func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) {
52
+
func Run(ctx context.Context) error {
54
53
logger := log.FromContext(ctx)
55
54
55
+
cfg, err := config.Load(ctx)
56
+
if err != nil {
57
+
return fmt.Errorf("failed to load config: %w", err)
58
+
}
59
+
56
60
d, err := db.Make(cfg.Server.DBPath)
57
61
if err != nil {
58
-
return nil, fmt.Errorf("failed to setup db: %w", err)
62
+
return fmt.Errorf("failed to setup db: %w", err)
59
63
}
60
64
61
65
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
62
66
if err != nil {
63
-
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
67
+
return fmt.Errorf("failed to setup rbac enforcer: %w", err)
64
68
}
65
69
e.E.EnableAutoSave(true)
66
70
···
70
74
switch cfg.Server.Secrets.Provider {
71
75
case "openbao":
72
76
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
73
-
return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
77
+
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
74
78
}
75
79
vault, err = secrets.NewOpenBaoManager(
76
80
cfg.Server.Secrets.OpenBao.ProxyAddr,
···
78
82
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
79
83
)
80
84
if err != nil {
81
-
return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err)
85
+
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
82
86
}
83
87
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
84
88
case "sqlite", "":
85
89
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
86
90
if err != nil {
87
-
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
91
+
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
88
92
}
89
93
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
90
94
default:
91
-
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
95
+
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
96
+
}
97
+
98
+
nixeryEng, err := nixery.New(ctx, cfg)
99
+
if err != nil {
100
+
return err
92
101
}
93
102
94
103
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
···
99
108
tangled.RepoNSID,
100
109
tangled.RepoCollaboratorNSID,
101
110
}
102
-
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
111
+
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
103
112
if err != nil {
104
-
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
113
+
return fmt.Errorf("failed to setup jetstream client: %w", err)
105
114
}
106
115
jc.AddDid(cfg.Server.Owner)
107
116
108
117
// Check if the spindle knows about any Dids;
109
118
dids, err := d.GetAllDids()
110
119
if err != nil {
111
-
return nil, fmt.Errorf("failed to get all dids: %w", err)
120
+
return fmt.Errorf("failed to get all dids: %w", err)
112
121
}
113
122
for _, d := range dids {
114
123
jc.AddDid(d)
115
124
}
116
125
117
-
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
126
+
resolver := idresolver.DefaultResolver()
118
127
119
-
spindle := &Spindle{
128
+
spindle := Spindle{
120
129
jc: jc,
121
130
e: e,
122
131
db: d,
123
132
l: logger,
124
133
n: &n,
125
-
engs: engines,
134
+
engs: map[string]models.Engine{"nixery": nixeryEng},
126
135
jq: jq,
127
136
cfg: cfg,
128
137
res: resolver,
···
131
140
132
141
err = e.AddSpindle(rbacDomain)
133
142
if err != nil {
134
-
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
143
+
return fmt.Errorf("failed to set rbac domain: %w", err)
135
144
}
136
145
err = spindle.configureOwner()
137
146
if err != nil {
138
-
return nil, err
147
+
return err
139
148
}
140
149
logger.Info("owner set", "did", cfg.Server.Owner)
141
150
151
+
// starts a job queue runner in the background
152
+
jq.Start()
153
+
defer jq.Stop()
154
+
155
+
// Stop vault token renewal if it implements Stopper
156
+
if stopper, ok := vault.(secrets.Stopper); ok {
157
+
defer stopper.Stop()
158
+
}
159
+
142
160
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
143
161
if err != nil {
144
-
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
162
+
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
145
163
}
146
164
147
165
err = jc.StartJetstream(ctx, spindle.ingest())
148
166
if err != nil {
149
-
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
167
+
return fmt.Errorf("failed to start jetstream consumer: %w", err)
150
168
}
151
169
152
170
// for each incoming sh.tangled.pipeline, we execute
153
171
// spindle.processPipeline, which in turn enqueues the pipeline
154
172
// job in the above registered queue.
155
173
ccfg := eventconsumer.NewConsumerConfig()
156
-
ccfg.Logger = log.SubLogger(logger, "eventconsumer")
174
+
ccfg.Logger = logger
157
175
ccfg.Dev = cfg.Server.Dev
158
176
ccfg.ProcessFunc = spindle.processPipeline
159
177
ccfg.CursorStore = cursorStore
160
178
knownKnots, err := d.Knots()
161
179
if err != nil {
162
-
return nil, err
180
+
return err
163
181
}
164
182
for _, knot := range knownKnots {
165
183
logger.Info("adding source start", "knot", knot)
···
167
185
}
168
186
spindle.ks = eventconsumer.NewConsumer(*ccfg)
169
187
170
-
return spindle, nil
171
-
}
172
-
173
-
// DB returns the database instance.
174
-
func (s *Spindle) DB() *db.DB {
175
-
return s.db
176
-
}
177
-
178
-
// Queue returns the job queue instance.
179
-
func (s *Spindle) Queue() *queue.Queue {
180
-
return s.jq
181
-
}
182
-
183
-
// Engines returns the map of available engines.
184
-
func (s *Spindle) Engines() map[string]models.Engine {
185
-
return s.engs
186
-
}
187
-
188
-
// Vault returns the secrets manager instance.
189
-
func (s *Spindle) Vault() secrets.Manager {
190
-
return s.vault
191
-
}
192
-
193
-
// Notifier returns the notifier instance.
194
-
func (s *Spindle) Notifier() *notifier.Notifier {
195
-
return s.n
196
-
}
197
-
198
-
// Enforcer returns the RBAC enforcer instance.
199
-
func (s *Spindle) Enforcer() *rbac.Enforcer {
200
-
return s.e
201
-
}
202
-
203
-
// Start starts the Spindle server (blocking).
204
-
func (s *Spindle) Start(ctx context.Context) error {
205
-
// starts a job queue runner in the background
206
-
s.jq.Start()
207
-
defer s.jq.Stop()
208
-
209
-
// Stop vault token renewal if it implements Stopper
210
-
if stopper, ok := s.vault.(secrets.Stopper); ok {
211
-
defer stopper.Stop()
212
-
}
213
-
214
188
go func() {
215
-
s.l.Info("starting knot event consumer")
216
-
s.ks.Start(ctx)
189
+
logger.Info("starting knot event consumer")
190
+
spindle.ks.Start(ctx)
217
191
}()
218
192
219
-
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
220
-
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
221
-
}
193
+
logger.Info("starting spindle server", "address", cfg.Server.ListenAddr)
194
+
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router()))
222
195
223
-
func Run(ctx context.Context) error {
224
-
cfg, err := config.Load(ctx)
225
-
if err != nil {
226
-
return fmt.Errorf("failed to load config: %w", err)
227
-
}
228
-
229
-
nixeryEng, err := nixery.New(ctx, cfg)
230
-
if err != nil {
231
-
return err
232
-
}
233
-
234
-
s, err := New(ctx, cfg, map[string]models.Engine{
235
-
"nixery": nixeryEng,
236
-
})
237
-
if err != nil {
238
-
return err
239
-
}
240
-
241
-
return s.Start(ctx)
196
+
return nil
242
197
}
243
198
244
199
func (s *Spindle) Router() http.Handler {
···
255
210
}
256
211
257
212
func (s *Spindle) XrpcRouter() http.Handler {
258
-
serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String())
213
+
logger := s.l.With("route", "xrpc")
259
214
260
-
l := log.SubLogger(s.l, "xrpc")
215
+
serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String())
261
216
262
217
x := xrpc.Xrpc{
263
-
Logger: l,
218
+
Logger: logger,
264
219
Db: s.db,
265
220
Enforcer: s.e,
266
221
Engines: s.engs,
···
350
305
351
306
ok := s.jq.Enqueue(queue.Job{
352
307
Run: func() error {
353
-
engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
308
+
engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
354
309
RepoOwner: tpl.TriggerMetadata.Repo.Did,
355
310
RepoName: tpl.TriggerMetadata.Repo.Repo,
356
311
Workflows: workflows,
+3
-8
spindle/stream.go
+3
-8
spindle/stream.go
···
10
10
"strconv"
11
11
"time"
12
12
13
-
"tangled.org/core/log"
14
13
"tangled.org/core/spindle/models"
15
14
16
15
"github.com/go-chi/chi/v5"
···
24
23
}
25
24
26
25
func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) {
27
-
l := log.SubLogger(s.l, "eventstream")
28
-
26
+
l := s.l.With("handler", "Events")
29
27
l.Debug("received new connection")
30
28
31
29
conn, err := upgrader.Upgrade(w, r, nil)
···
84
82
}
85
83
case <-time.After(30 * time.Second):
86
84
// send a keep-alive
85
+
l.Debug("sent keepalive")
87
86
if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
88
87
l.Error("failed to write control", "err", err)
89
88
}
···
213
212
if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil {
214
213
return fmt.Errorf("failed to write to websocket: %w", err)
215
214
}
216
-
case <-time.After(30 * time.Second):
217
-
// send a keep-alive
218
-
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
219
-
return fmt.Errorf("failed to write control: %w", err)
220
-
}
221
215
}
222
216
}
223
217
}
···
228
222
s.l.Debug("err", "err", err)
229
223
return err
230
224
}
225
+
s.l.Debug("ops", "ops", events)
231
226
232
227
for _, event := range events {
233
228
// first extract the inner json into a map
+5
-7
types/repo.go
+5
-7
types/repo.go
···
1
1
package types
2
2
3
3
import (
4
-
"github.com/bluekeyes/go-gitdiff/gitdiff"
5
4
"github.com/go-git/go-git/v5/plumbing/object"
6
5
)
7
6
···
34
33
}
35
34
36
35
type RepoFormatPatchResponse struct {
37
-
Rev1 string `json:"rev1,omitempty"`
38
-
Rev2 string `json:"rev2,omitempty"`
39
-
FormatPatch []FormatPatch `json:"format_patch,omitempty"`
40
-
FormatPatchRaw string `json:"patch,omitempty"`
41
-
CombinedPatch []*gitdiff.File `json:"combined_patch,omitempty"`
42
-
CombinedPatchRaw string `json:"combined_patch_raw,omitempty"`
36
+
Rev1 string `json:"rev1,omitempty"`
37
+
Rev2 string `json:"rev2,omitempty"`
38
+
FormatPatch []FormatPatch `json:"format_patch,omitempty"`
39
+
MergeBase string `json:"merge_base,omitempty"` // deprecated
40
+
Patch string `json:"patch,omitempty"`
43
41
}
44
42
45
43
type RepoTreeResponse struct {
+5
-28
types/tree.go
+5
-28
types/tree.go
···
4
4
"time"
5
5
6
6
"github.com/go-git/go-git/v5/plumbing"
7
-
"github.com/go-git/go-git/v5/plumbing/filemode"
8
7
)
9
8
10
9
// A nicer git tree representation.
11
10
type NiceTree struct {
12
11
// Relative path
13
-
Name string `json:"name"`
14
-
Mode string `json:"mode"`
15
-
Size int64 `json:"size"`
12
+
Name string `json:"name"`
13
+
Mode string `json:"mode"`
14
+
Size int64 `json:"size"`
15
+
IsFile bool `json:"is_file"`
16
+
IsSubtree bool `json:"is_subtree"`
16
17
17
18
LastCommit *LastCommitInfo `json:"last_commit,omitempty"`
18
-
}
19
-
20
-
func (t *NiceTree) FileMode() (filemode.FileMode, error) {
21
-
return filemode.New(t.Mode)
22
-
}
23
-
24
-
func (t *NiceTree) IsFile() bool {
25
-
m, err := t.FileMode()
26
-
27
-
if err != nil {
28
-
return false
29
-
}
30
-
31
-
return m.IsFile()
32
-
}
33
-
34
-
func (t *NiceTree) IsSubmodule() bool {
35
-
m, err := t.FileMode()
36
-
37
-
if err != nil {
38
-
return false
39
-
}
40
-
41
-
return m == filemode.Submodule
42
19
}
43
20
44
21
type LastCommitInfo struct {
+1
-9
workflow/compile.go
+1
-9
workflow/compile.go
···
113
113
func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow {
114
114
cw := &tangled.Pipeline_Workflow{}
115
115
116
-
matched, err := w.Match(compiler.Trigger)
117
-
if err != nil {
118
-
compiler.Diagnostics.AddError(
119
-
w.Name,
120
-
fmt.Errorf("failed to execute workflow: %w", err),
121
-
)
122
-
return nil
123
-
}
124
-
if !matched {
116
+
if !w.Match(compiler.Trigger) {
125
117
compiler.Diagnostics.AddWarning(
126
118
w.Name,
127
119
WorkflowSkipped,
-125
workflow/compile_test.go
-125
workflow/compile_test.go
···
95
95
assert.Len(t, c.Diagnostics.Errors, 1)
96
96
assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error)
97
97
}
98
-
99
-
func TestCompileWorkflow_MultipleBranchAndTag(t *testing.T) {
100
-
wf := Workflow{
101
-
Name: ".tangled/workflows/branch_and_tag.yml",
102
-
When: []Constraint{
103
-
{
104
-
Event: []string{"push"},
105
-
Branch: []string{"main", "develop"},
106
-
Tag: []string{"v*"},
107
-
},
108
-
},
109
-
Engine: "nixery",
110
-
}
111
-
112
-
tests := []struct {
113
-
name string
114
-
trigger tangled.Pipeline_TriggerMetadata
115
-
shouldMatch bool
116
-
expectedCount int
117
-
}{
118
-
{
119
-
name: "matches main branch",
120
-
trigger: tangled.Pipeline_TriggerMetadata{
121
-
Kind: string(TriggerKindPush),
122
-
Push: &tangled.Pipeline_PushTriggerData{
123
-
Ref: "refs/heads/main",
124
-
OldSha: strings.Repeat("0", 40),
125
-
NewSha: strings.Repeat("f", 40),
126
-
},
127
-
},
128
-
shouldMatch: true,
129
-
expectedCount: 1,
130
-
},
131
-
{
132
-
name: "matches develop branch",
133
-
trigger: tangled.Pipeline_TriggerMetadata{
134
-
Kind: string(TriggerKindPush),
135
-
Push: &tangled.Pipeline_PushTriggerData{
136
-
Ref: "refs/heads/develop",
137
-
OldSha: strings.Repeat("0", 40),
138
-
NewSha: strings.Repeat("f", 40),
139
-
},
140
-
},
141
-
shouldMatch: true,
142
-
expectedCount: 1,
143
-
},
144
-
{
145
-
name: "matches v* tag pattern",
146
-
trigger: tangled.Pipeline_TriggerMetadata{
147
-
Kind: string(TriggerKindPush),
148
-
Push: &tangled.Pipeline_PushTriggerData{
149
-
Ref: "refs/tags/v1.0.0",
150
-
OldSha: strings.Repeat("0", 40),
151
-
NewSha: strings.Repeat("f", 40),
152
-
},
153
-
},
154
-
shouldMatch: true,
155
-
expectedCount: 1,
156
-
},
157
-
{
158
-
name: "matches v* tag pattern with different version",
159
-
trigger: tangled.Pipeline_TriggerMetadata{
160
-
Kind: string(TriggerKindPush),
161
-
Push: &tangled.Pipeline_PushTriggerData{
162
-
Ref: "refs/tags/v2.5.3",
163
-
OldSha: strings.Repeat("0", 40),
164
-
NewSha: strings.Repeat("f", 40),
165
-
},
166
-
},
167
-
shouldMatch: true,
168
-
expectedCount: 1,
169
-
},
170
-
{
171
-
name: "does not match master branch",
172
-
trigger: tangled.Pipeline_TriggerMetadata{
173
-
Kind: string(TriggerKindPush),
174
-
Push: &tangled.Pipeline_PushTriggerData{
175
-
Ref: "refs/heads/master",
176
-
OldSha: strings.Repeat("0", 40),
177
-
NewSha: strings.Repeat("f", 40),
178
-
},
179
-
},
180
-
shouldMatch: false,
181
-
expectedCount: 0,
182
-
},
183
-
{
184
-
name: "does not match non-v tag",
185
-
trigger: tangled.Pipeline_TriggerMetadata{
186
-
Kind: string(TriggerKindPush),
187
-
Push: &tangled.Pipeline_PushTriggerData{
188
-
Ref: "refs/tags/release-1.0",
189
-
OldSha: strings.Repeat("0", 40),
190
-
NewSha: strings.Repeat("f", 40),
191
-
},
192
-
},
193
-
shouldMatch: false,
194
-
expectedCount: 0,
195
-
},
196
-
{
197
-
name: "does not match feature branch",
198
-
trigger: tangled.Pipeline_TriggerMetadata{
199
-
Kind: string(TriggerKindPush),
200
-
Push: &tangled.Pipeline_PushTriggerData{
201
-
Ref: "refs/heads/feature/new-feature",
202
-
OldSha: strings.Repeat("0", 40),
203
-
NewSha: strings.Repeat("f", 40),
204
-
},
205
-
},
206
-
shouldMatch: false,
207
-
expectedCount: 0,
208
-
},
209
-
}
210
-
211
-
for _, tt := range tests {
212
-
t.Run(tt.name, func(t *testing.T) {
213
-
c := Compiler{Trigger: tt.trigger}
214
-
cp := c.Compile([]Workflow{wf})
215
-
216
-
assert.Len(t, cp.Workflows, tt.expectedCount)
217
-
if tt.shouldMatch {
218
-
assert.Equal(t, wf.Name, cp.Workflows[0].Name)
219
-
}
220
-
})
221
-
}
222
-
}
+19
-61
workflow/def.go
+19
-61
workflow/def.go
···
8
8
9
9
"tangled.org/core/api/tangled"
10
10
11
-
"github.com/bmatcuk/doublestar/v4"
12
11
"github.com/go-git/go-git/v5/plumbing"
13
12
"gopkg.in/yaml.v3"
14
13
)
···
34
33
35
34
Constraint struct {
36
35
Event StringList `yaml:"event"`
37
-
Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified
38
-
Tag StringList `yaml:"tag"` // optional; only applies to push events
36
+
Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events
39
37
}
40
38
41
39
CloneOpts struct {
···
61
59
return strings.ReplaceAll(string(t), "_", " ")
62
60
}
63
61
64
-
// matchesPattern checks if a name matches any of the given patterns.
65
-
// Patterns can be exact matches or glob patterns using * and **.
66
-
// * matches any sequence of non-separator characters
67
-
// ** matches any sequence of characters including separators
68
-
func matchesPattern(name string, patterns []string) (bool, error) {
69
-
for _, pattern := range patterns {
70
-
matched, err := doublestar.Match(pattern, name)
71
-
if err != nil {
72
-
return false, err
73
-
}
74
-
if matched {
75
-
return true, nil
76
-
}
77
-
}
78
-
return false, nil
79
-
}
80
-
81
62
func FromFile(name string, contents []byte) (Workflow, error) {
82
63
var wf Workflow
83
64
···
93
74
}
94
75
95
76
// if any of the constraints on a workflow is true, return true
96
-
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
77
+
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
97
78
// manual triggers always run the workflow
98
79
if trigger.Manual != nil {
99
-
return true, nil
80
+
return true
100
81
}
101
82
102
83
// if not manual, run through the constraint list and see if any one matches
103
84
for _, c := range w.When {
104
-
matched, err := c.Match(trigger)
105
-
if err != nil {
106
-
return false, err
107
-
}
108
-
if matched {
109
-
return true, nil
85
+
if c.Match(trigger) {
86
+
return true
110
87
}
111
88
}
112
89
113
90
// no constraints, always run this workflow
114
91
if len(w.When) == 0 {
115
-
return true, nil
92
+
return true
116
93
}
117
94
118
-
return false, nil
95
+
return false
119
96
}
120
97
121
-
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
98
+
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
122
99
match := true
123
100
124
101
// manual triggers always pass this constraint
125
102
if trigger.Manual != nil {
126
-
return true, nil
103
+
return true
127
104
}
128
105
129
106
// apply event constraints
···
131
108
132
109
// apply branch constraints for PRs
133
110
if trigger.PullRequest != nil {
134
-
matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch)
135
-
if err != nil {
136
-
return false, err
137
-
}
138
-
match = match && matched
111
+
match = match && c.MatchBranch(trigger.PullRequest.TargetBranch)
139
112
}
140
113
141
114
// apply ref constraints for pushes
142
115
if trigger.Push != nil {
143
-
matched, err := c.MatchRef(trigger.Push.Ref)
144
-
if err != nil {
145
-
return false, err
146
-
}
147
-
match = match && matched
116
+
match = match && c.MatchRef(trigger.Push.Ref)
148
117
}
149
118
150
-
return match, nil
119
+
return match
151
120
}
152
121
153
-
func (c *Constraint) MatchRef(ref string) (bool, error) {
154
-
refName := plumbing.ReferenceName(ref)
155
-
shortName := refName.Short()
122
+
func (c *Constraint) MatchBranch(branch string) bool {
123
+
return slices.Contains(c.Branch, branch)
124
+
}
156
125
126
+
func (c *Constraint) MatchRef(ref string) bool {
127
+
refName := plumbing.ReferenceName(ref)
157
128
if refName.IsBranch() {
158
-
return c.MatchBranch(shortName)
159
-
}
160
-
161
-
if refName.IsTag() {
162
-
return c.MatchTag(shortName)
129
+
return slices.Contains(c.Branch, refName.Short())
163
130
}
164
-
165
-
return false, nil
166
-
}
167
-
168
-
func (c *Constraint) MatchBranch(branch string) (bool, error) {
169
-
return matchesPattern(branch, c.Branch)
170
-
}
171
-
172
-
func (c *Constraint) MatchTag(tag string) (bool, error) {
173
-
return matchesPattern(tag, c.Tag)
131
+
return false
174
132
}
175
133
176
134
func (c *Constraint) MatchEvent(event string) bool {
+1
-284
workflow/def_test.go
+1
-284
workflow/def_test.go
···
6
6
"github.com/stretchr/testify/assert"
7
7
)
8
8
9
-
func TestUnmarshalWorkflowWithBranch(t *testing.T) {
9
+
func TestUnmarshalWorkflow(t *testing.T) {
10
10
yamlData := `
11
11
when:
12
12
- event: ["push", "pull_request"]
···
38
38
39
39
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
40
40
}
41
-
42
-
func TestUnmarshalWorkflowWithTags(t *testing.T) {
43
-
yamlData := `
44
-
when:
45
-
- event: ["push"]
46
-
tag: ["v*", "release-*"]`
47
-
48
-
wf, err := FromFile("test.yml", []byte(yamlData))
49
-
assert.NoError(t, err, "YAML should unmarshal without error")
50
-
51
-
assert.Len(t, wf.When, 1, "Should have one constraint")
52
-
assert.ElementsMatch(t, []string{"v*", "release-*"}, wf.When[0].Tag)
53
-
assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event)
54
-
}
55
-
56
-
func TestUnmarshalWorkflowWithBranchAndTag(t *testing.T) {
57
-
yamlData := `
58
-
when:
59
-
- event: ["push"]
60
-
branch: ["main", "develop"]
61
-
tag: ["v*"]`
62
-
63
-
wf, err := FromFile("test.yml", []byte(yamlData))
64
-
assert.NoError(t, err, "YAML should unmarshal without error")
65
-
66
-
assert.Len(t, wf.When, 1, "Should have one constraint")
67
-
assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
68
-
assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag)
69
-
}
70
-
71
-
func TestMatchesPattern(t *testing.T) {
72
-
tests := []struct {
73
-
name string
74
-
input string
75
-
patterns []string
76
-
expected bool
77
-
}{
78
-
{"exact match", "main", []string{"main"}, true},
79
-
{"exact match in list", "develop", []string{"main", "develop"}, true},
80
-
{"no match", "feature", []string{"main", "develop"}, false},
81
-
{"wildcard prefix", "v1.0.0", []string{"v*"}, true},
82
-
{"wildcard suffix", "release-1.0", []string{"*-1.0"}, true},
83
-
{"wildcard middle", "feature-123-test", []string{"feature-*-test"}, true},
84
-
{"double star prefix", "release-1.0.0", []string{"release-**"}, true},
85
-
{"double star with slashes", "release/1.0/hotfix", []string{"release/**"}, true},
86
-
{"double star matches multiple levels", "foo/bar/baz/qux", []string{"foo/**"}, true},
87
-
{"double star no match", "feature/test", []string{"release/**"}, false},
88
-
{"no patterns matches nothing", "anything", []string{}, false},
89
-
{"pattern doesn't match", "v1.0.0", []string{"release-*"}, false},
90
-
{"complex pattern", "release/v1.2.3", []string{"release/*"}, true},
91
-
{"single star stops at slash", "release/1.0/hotfix", []string{"release/*"}, false},
92
-
}
93
-
94
-
for _, tt := range tests {
95
-
t.Run(tt.name, func(t *testing.T) {
96
-
result, _ := matchesPattern(tt.input, tt.patterns)
97
-
assert.Equal(t, tt.expected, result, "matchesPattern(%q, %v) should be %v", tt.input, tt.patterns, tt.expected)
98
-
})
99
-
}
100
-
}
101
-
102
-
func TestConstraintMatchRef_Branches(t *testing.T) {
103
-
tests := []struct {
104
-
name string
105
-
constraint Constraint
106
-
ref string
107
-
expected bool
108
-
}{
109
-
{
110
-
name: "exact branch match",
111
-
constraint: Constraint{Branch: []string{"main"}},
112
-
ref: "refs/heads/main",
113
-
expected: true,
114
-
},
115
-
{
116
-
name: "branch glob match",
117
-
constraint: Constraint{Branch: []string{"feature-*"}},
118
-
ref: "refs/heads/feature-123",
119
-
expected: true,
120
-
},
121
-
{
122
-
name: "branch no match",
123
-
constraint: Constraint{Branch: []string{"main"}},
124
-
ref: "refs/heads/develop",
125
-
expected: false,
126
-
},
127
-
{
128
-
name: "no constraints matches nothing",
129
-
constraint: Constraint{},
130
-
ref: "refs/heads/anything",
131
-
expected: false,
132
-
},
133
-
}
134
-
135
-
for _, tt := range tests {
136
-
t.Run(tt.name, func(t *testing.T) {
137
-
result, _ := tt.constraint.MatchRef(tt.ref)
138
-
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
139
-
})
140
-
}
141
-
}
142
-
143
-
func TestConstraintMatchRef_Tags(t *testing.T) {
144
-
tests := []struct {
145
-
name string
146
-
constraint Constraint
147
-
ref string
148
-
expected bool
149
-
}{
150
-
{
151
-
name: "exact tag match",
152
-
constraint: Constraint{Tag: []string{"v1.0.0"}},
153
-
ref: "refs/tags/v1.0.0",
154
-
expected: true,
155
-
},
156
-
{
157
-
name: "tag glob match",
158
-
constraint: Constraint{Tag: []string{"v*"}},
159
-
ref: "refs/tags/v1.2.3",
160
-
expected: true,
161
-
},
162
-
{
163
-
name: "tag glob with pattern",
164
-
constraint: Constraint{Tag: []string{"release-*"}},
165
-
ref: "refs/tags/release-2024",
166
-
expected: true,
167
-
},
168
-
{
169
-
name: "tag no match",
170
-
constraint: Constraint{Tag: []string{"v*"}},
171
-
ref: "refs/tags/release-1.0",
172
-
expected: false,
173
-
},
174
-
{
175
-
name: "tag not matched when only branch constraint",
176
-
constraint: Constraint{Branch: []string{"main"}},
177
-
ref: "refs/tags/v1.0.0",
178
-
expected: false,
179
-
},
180
-
}
181
-
182
-
for _, tt := range tests {
183
-
t.Run(tt.name, func(t *testing.T) {
184
-
result, _ := tt.constraint.MatchRef(tt.ref)
185
-
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
186
-
})
187
-
}
188
-
}
189
-
190
-
func TestConstraintMatchRef_Combined(t *testing.T) {
191
-
tests := []struct {
192
-
name string
193
-
constraint Constraint
194
-
ref string
195
-
expected bool
196
-
}{
197
-
{
198
-
name: "matches branch in combined constraint",
199
-
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
200
-
ref: "refs/heads/main",
201
-
expected: true,
202
-
},
203
-
{
204
-
name: "matches tag in combined constraint",
205
-
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
206
-
ref: "refs/tags/v1.0.0",
207
-
expected: true,
208
-
},
209
-
{
210
-
name: "no match in combined constraint",
211
-
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
212
-
ref: "refs/heads/develop",
213
-
expected: false,
214
-
},
215
-
{
216
-
name: "glob patterns in combined constraint - branch",
217
-
constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
218
-
ref: "refs/heads/release-2024",
219
-
expected: true,
220
-
},
221
-
{
222
-
name: "glob patterns in combined constraint - tag",
223
-
constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
224
-
ref: "refs/tags/v2.0.0",
225
-
expected: true,
226
-
},
227
-
}
228
-
229
-
for _, tt := range tests {
230
-
t.Run(tt.name, func(t *testing.T) {
231
-
result, _ := tt.constraint.MatchRef(tt.ref)
232
-
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
233
-
})
234
-
}
235
-
}
236
-
237
-
func TestConstraintMatchBranch_GlobPatterns(t *testing.T) {
238
-
tests := []struct {
239
-
name string
240
-
constraint Constraint
241
-
branch string
242
-
expected bool
243
-
}{
244
-
{
245
-
name: "exact match",
246
-
constraint: Constraint{Branch: []string{"main"}},
247
-
branch: "main",
248
-
expected: true,
249
-
},
250
-
{
251
-
name: "glob match",
252
-
constraint: Constraint{Branch: []string{"feature-*"}},
253
-
branch: "feature-123",
254
-
expected: true,
255
-
},
256
-
{
257
-
name: "no match",
258
-
constraint: Constraint{Branch: []string{"main"}},
259
-
branch: "develop",
260
-
expected: false,
261
-
},
262
-
{
263
-
name: "multiple patterns with match",
264
-
constraint: Constraint{Branch: []string{"main", "release-*"}},
265
-
branch: "release-1.0",
266
-
expected: true,
267
-
},
268
-
}
269
-
270
-
for _, tt := range tests {
271
-
t.Run(tt.name, func(t *testing.T) {
272
-
result, _ := tt.constraint.MatchBranch(tt.branch)
273
-
assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch)
274
-
})
275
-
}
276
-
}
277
-
278
-
func TestConstraintMatchTag_GlobPatterns(t *testing.T) {
279
-
tests := []struct {
280
-
name string
281
-
constraint Constraint
282
-
tag string
283
-
expected bool
284
-
}{
285
-
{
286
-
name: "exact match",
287
-
constraint: Constraint{Tag: []string{"v1.0.0"}},
288
-
tag: "v1.0.0",
289
-
expected: true,
290
-
},
291
-
{
292
-
name: "glob match",
293
-
constraint: Constraint{Tag: []string{"v*"}},
294
-
tag: "v2.3.4",
295
-
expected: true,
296
-
},
297
-
{
298
-
name: "no match",
299
-
constraint: Constraint{Tag: []string{"v*"}},
300
-
tag: "release-1.0",
301
-
expected: false,
302
-
},
303
-
{
304
-
name: "multiple patterns with match",
305
-
constraint: Constraint{Tag: []string{"v*", "release-*"}},
306
-
tag: "release-2024",
307
-
expected: true,
308
-
},
309
-
{
310
-
name: "empty tag list matches nothing",
311
-
constraint: Constraint{Tag: []string{}},
312
-
tag: "v1.0.0",
313
-
expected: false,
314
-
},
315
-
}
316
-
317
-
for _, tt := range tests {
318
-
t.Run(tt.name, func(t *testing.T) {
319
-
result, _ := tt.constraint.MatchTag(tt.tag)
320
-
assert.Equal(t, tt.expected, result, "MatchTag should return %v for tag %q", tt.expected, tt.tag)
321
-
})
322
-
}
323
-
}
+4
-5
xrpc/serviceauth/service_auth.go
+4
-5
xrpc/serviceauth/service_auth.go
···
9
9
10
10
"github.com/bluesky-social/indigo/atproto/auth"
11
11
"tangled.org/core/idresolver"
12
-
"tangled.org/core/log"
13
12
xrpcerr "tangled.org/core/xrpc/errors"
14
13
)
15
14
···
23
22
24
23
func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth {
25
24
return &ServiceAuth{
26
-
logger: log.SubLogger(logger, "serviceauth"),
25
+
logger: logger,
27
26
resolver: resolver,
28
27
audienceDid: audienceDid,
29
28
}
···
31
30
32
31
func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler {
33
32
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33
+
l := sa.logger.With("url", r.URL)
34
+
34
35
token := r.Header.Get("Authorization")
35
36
token = strings.TrimPrefix(token, "Bearer ")
36
37
···
41
42
42
43
did, err := s.Validate(r.Context(), token, nil)
43
44
if err != nil {
44
-
sa.logger.Error("signature verification failed", "err", err)
45
+
l.Error("signature verification failed", "err", err)
45
46
writeError(w, xrpcerr.AuthError(err), http.StatusForbidden)
46
47
return
47
48
}
48
-
49
-
sa.logger.Debug("valid signature", ActorDid, did)
50
49
51
50
r = r.WithContext(
52
51
context.WithValue(r.Context(), ActorDid, did),