+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
+3
-1
api/tangled/actorprofile.go
+3
-1
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
-
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
30
+
// pronouns: Preferred gender pronouns.
31
+
Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"`
32
+
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
31
33
}
+196
-2
api/tangled/cbor_gen.go
+196
-2
api/tangled/cbor_gen.go
···
26
26
}
27
27
28
28
cw := cbg.NewCborWriter(w)
29
-
fieldCount := 7
29
+
fieldCount := 8
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 {
44
48
fieldCount--
45
49
}
46
50
···
186
190
return err
187
191
}
188
192
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 {
189
225
return err
190
226
}
191
227
}
···
430
466
}
431
467
432
468
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)
433
490
}
434
491
}
435
492
// t.Description (string) (string)
···
5806
5863
}
5807
5864
5808
5865
cw := cbg.NewCborWriter(w)
5809
-
fieldCount := 8
5866
+
fieldCount := 10
5810
5867
5811
5868
if t.Description == nil {
5812
5869
fieldCount--
···
5821
5878
}
5822
5879
5823
5880
if t.Spindle == nil {
5881
+
fieldCount--
5882
+
}
5883
+
5884
+
if t.Topics == nil {
5885
+
fieldCount--
5886
+
}
5887
+
5888
+
if t.Website == nil {
5824
5889
fieldCount--
5825
5890
}
5826
5891
···
5961
6026
}
5962
6027
}
5963
6028
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
+
5964
6065
// t.Spindle (string) (string)
5965
6066
if t.Spindle != nil {
5966
6067
···
5993
6094
}
5994
6095
}
5995
6096
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
+
5996
6129
// t.CreatedAt (string) (string)
5997
6130
if len("createdAt") > 1000000 {
5998
6131
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6185
6318
t.Source = (*string)(&sval)
6186
6319
}
6187
6320
}
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
+
}
6188
6361
// t.Spindle (string) (string)
6189
6362
case "spindle":
6190
6363
···
6204
6377
}
6205
6378
6206
6379
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)
6207
6401
}
6208
6402
}
6209
6403
// t.CreatedAt (string) (string)
+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/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"`
33
37
}
+15
-2
appview/config/config.go
+15
-2
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.sh"`
16
+
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.org"`
17
+
AppviewName string `env:"APPVIEW_Name, default=Tangled"`
17
18
Dev bool `env:"DEV, default=false"`
18
19
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
19
20
···
25
26
}
26
27
27
28
type OAuthConfig struct {
28
-
Jwks string `env:"JWKS"`
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"`
29
35
}
30
36
31
37
type JetstreamConfig struct {
···
78
84
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
79
85
}
80
86
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
+
81
92
func (cfg RedisConfig) ToURL() string {
82
93
u := &url.URL{
83
94
Scheme: "redis",
···
103
114
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
104
115
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
105
116
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
117
+
Plc PlcConfig `env:",prefix=TANGLED_PLC_"`
106
118
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
107
119
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
120
+
Label LabelConfig `env:",prefix=TANGLED_LABEL_"`
108
121
}
109
122
110
123
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"
6
7
7
8
"tangled.org/core/appview/models"
8
9
)
···
59
60
60
61
return GetRepos(e, 0, FilterIn("at_uri", repoAts))
61
62
}
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
+
}
+205
-26
appview/db/db.go
+205
-26
appview/db/db.go
···
4
4
"context"
5
5
"database/sql"
6
6
"fmt"
7
-
"log"
7
+
"log/slog"
8
8
"reflect"
9
9
"strings"
10
10
11
11
_ "github.com/mattn/go-sqlite3"
12
+
"tangled.org/core/log"
12
13
)
13
14
14
15
type DB struct {
15
16
*sql.DB
17
+
logger *slog.Logger
16
18
}
17
19
18
20
type Execer interface {
···
26
28
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
27
29
}
28
30
29
-
func Make(dbPath string) (*DB, error) {
31
+
func Make(ctx context.Context, dbPath string) (*DB, error) {
30
32
// https://github.com/mattn/go-sqlite3#connection-string
31
33
opts := []string{
32
34
"_foreign_keys=1",
···
35
37
"_auto_vacuum=incremental",
36
38
}
37
39
40
+
logger := log.FromContext(ctx)
41
+
logger = log.SubLogger(logger, "db")
42
+
38
43
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
39
44
if err != nil {
40
45
return nil, err
41
46
}
42
-
43
-
ctx := context.Background()
44
47
45
48
conn, err := db.Conn(ctx)
46
49
if err != nil {
···
574
577
}
575
578
576
579
// run migrations
577
-
runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error {
580
+
runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error {
578
581
tx.Exec(`
579
582
alter table repos add column description text check (length(description) <= 200);
580
583
`)
581
584
return nil
582
585
})
583
586
584
-
runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
587
+
runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
585
588
// add unconstrained column
586
589
_, err := tx.Exec(`
587
590
alter table public_keys
···
604
607
return nil
605
608
})
606
609
607
-
runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error {
610
+
runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error {
608
611
_, err := tx.Exec(`
609
612
alter table comments drop column comment_at;
610
613
alter table comments add column rkey text;
···
612
615
return err
613
616
})
614
617
615
-
runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
618
+
runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
616
619
_, err := tx.Exec(`
617
620
alter table comments add column deleted text; -- timestamp
618
621
alter table comments add column edited text; -- timestamp
···
620
623
return err
621
624
})
622
625
623
-
runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
626
+
runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
624
627
_, err := tx.Exec(`
625
628
alter table pulls add column source_branch text;
626
629
alter table pulls add column source_repo_at text;
···
629
632
return err
630
633
})
631
634
632
-
runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error {
635
+
runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error {
633
636
_, err := tx.Exec(`
634
637
alter table repos add column source text;
635
638
`)
···
641
644
//
642
645
// [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
643
646
conn.ExecContext(ctx, "pragma foreign_keys = off;")
644
-
runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
647
+
runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
645
648
_, err := tx.Exec(`
646
649
create table pulls_new (
647
650
-- identifiers
···
698
701
})
699
702
conn.ExecContext(ctx, "pragma foreign_keys = on;")
700
703
701
-
runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error {
704
+
runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error {
702
705
tx.Exec(`
703
706
alter table repos add column spindle text;
704
707
`)
···
708
711
// drop all knot secrets, add unique constraint to knots
709
712
//
710
713
// knots will henceforth use service auth for signed requests
711
-
runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error {
714
+
runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error {
712
715
_, err := tx.Exec(`
713
716
create table registrations_new (
714
717
id integer primary key autoincrement,
···
731
734
})
732
735
733
736
// recreate and add rkey + created columns with default constraint
734
-
runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error {
737
+
runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error {
735
738
// create new table
736
739
// - repo_at instead of repo integer
737
740
// - rkey field
···
785
788
return err
786
789
})
787
790
788
-
runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error {
791
+
runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error {
789
792
_, err := tx.Exec(`
790
793
alter table issues add column rkey text not null default '';
791
794
···
797
800
})
798
801
799
802
// repurpose the read-only column to "needs-upgrade"
800
-
runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
803
+
runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
801
804
_, err := tx.Exec(`
802
805
alter table registrations rename column read_only to needs_upgrade;
803
806
`)
···
805
808
})
806
809
807
810
// require all knots to upgrade after the release of total xrpc
808
-
runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
811
+
runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
809
812
_, err := tx.Exec(`
810
813
update registrations set needs_upgrade = 1;
811
814
`)
···
813
816
})
814
817
815
818
// require all knots to upgrade after the release of total xrpc
816
-
runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
819
+
runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
817
820
_, err := tx.Exec(`
818
821
alter table spindles add column needs_upgrade integer not null default 0;
819
822
`)
···
831
834
//
832
835
// disable foreign-keys for the next migration
833
836
conn.ExecContext(ctx, "pragma foreign_keys = off;")
834
-
runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
837
+
runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
835
838
_, err := tx.Exec(`
836
839
create table if not exists issues_new (
837
840
-- identifiers
···
901
904
// - new columns
902
905
// * column "reply_to" which can be any other comment
903
906
// * column "at-uri" which is a generated column
904
-
runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error {
907
+
runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error {
905
908
_, err := tx.Exec(`
906
909
create table if not exists issue_comments (
907
910
-- identifiers
···
954
957
return err
955
958
})
956
959
957
-
return &DB{db}, nil
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
958
1135
}
959
1136
960
1137
type migrationFn = func(*sql.Tx) error
961
1138
962
-
func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error {
1139
+
func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error {
1140
+
logger = logger.With("migration", name)
1141
+
963
1142
tx, err := c.BeginTx(context.Background(), nil)
964
1143
if err != nil {
965
1144
return err
···
976
1155
// run migration
977
1156
err = migrationFn(tx)
978
1157
if err != nil {
979
-
log.Printf("Failed to run migration %s: %v", name, err)
1158
+
logger.Error("failed to run migration", "err", err)
980
1159
return err
981
1160
}
982
1161
983
1162
// mark migration as complete
984
1163
_, err = tx.Exec("insert into migrations (name) values (?)", name)
985
1164
if err != nil {
986
-
log.Printf("Failed to mark migration %s as complete: %v", name, err)
1165
+
logger.Error("failed to mark migration as complete", "err", err)
987
1166
return err
988
1167
}
989
1168
···
992
1171
return err
993
1172
}
994
1173
995
-
log.Printf("migration %s applied successfully", name)
1174
+
logger.Info("migration applied successfully")
996
1175
} else {
997
-
log.Printf("skipped migration %s, already applied", name)
1176
+
logger.Warn("skipped migration, already applied")
998
1177
}
999
1178
1000
1179
return nil
+13
-9
appview/db/email.go
+13
-9
appview/db/email.go
···
71
71
return did, nil
72
72
}
73
73
74
-
func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) {
75
-
if len(ems) == 0 {
74
+
func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) {
75
+
if len(emails) == 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)
83
85
84
86
// Create placeholders for the IN clause
85
-
placeholders := make([]string, len(ems))
86
-
args := make([]any, len(ems)+1)
87
+
placeholders := make([]string, 0, len(emails))
88
+
args := make([]any, 1, len(emails)+1)
87
89
88
90
args[0] = verifiedFilter
89
-
for i, em := range ems {
90
-
placeholders[i] = "?"
91
-
args[i+1] = em
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)
92
98
}
93
99
94
100
query := `
···
104
110
return nil, err
105
111
}
106
112
defer rows.Close()
107
-
108
-
assoc := make(map[string]string)
109
113
110
114
for rows.Next() {
111
115
var email, did string
+72
-16
appview/db/issues.go
+72
-16
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
-
args = append(args, pLower.Arg()...)
105
-
args = append(args, pUpper.Arg()...)
106
-
pagination := " where " + pLower.Condition() + " and " + pUpper.Condition()
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
+
}
107
110
108
111
query := fmt.Sprintf(
109
112
`
···
128
131
%s
129
132
`,
130
133
whereClause,
131
-
pagination,
134
+
pageClause,
132
135
)
133
136
134
137
rows, err := e.Query(query, args...)
···
243
246
return issues, nil
244
247
}
245
248
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
+
246
266
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
247
-
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
267
+
return GetIssuesPaginated(e, pagination.Page{}, filters...)
248
268
}
249
269
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)
270
+
// GetIssueIDs gets list of all existing issue's IDs
271
+
func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) {
272
+
var ids []int64
273
+
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))
282
+
}
283
+
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
+
}
253
291
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)
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...)
257
308
if err != nil {
258
309
return nil, err
259
310
}
311
+
defer rows.Close()
260
312
261
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
262
-
if err != nil {
263
-
return nil, err
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)
264
321
}
265
-
issue.Created = createdTime
266
322
267
-
return &issue, nil
323
+
return ids, nil
268
324
}
269
325
270
326
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"
4
5
"fmt"
5
6
"strings"
6
7
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
9
"tangled.org/core/appview/models"
8
10
)
9
11
···
82
84
83
85
return nil
84
86
}
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
+
}
+121
-79
appview/db/notifications.go
+121
-79
appview/db/notifications.go
···
3
3
import (
4
4
"context"
5
5
"database/sql"
6
+
"errors"
6
7
"fmt"
8
+
"strings"
7
9
"time"
8
10
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
12
"tangled.org/core/appview/models"
10
13
"tangled.org/core/appview/pagination"
11
14
)
12
15
13
-
func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error {
16
+
func CreateNotification(e Execer, notification *models.Notification) error {
14
17
query := `
15
18
INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id)
16
19
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
17
20
`
18
21
19
-
result, err := d.DB.ExecContext(ctx, query,
22
+
result, err := e.Exec(query,
20
23
notification.RecipientDid,
21
24
notification.ActorDid,
22
25
string(notification.Type),
···
57
60
whereClause += " AND " + condition
58
61
}
59
62
}
63
+
pageClause := ""
64
+
if page.Limit > 0 {
65
+
pageClause = " limit ? offset ? "
66
+
args = append(args, page.Limit, page.Offset)
67
+
}
60
68
61
69
query := fmt.Sprintf(`
62
70
select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id
63
71
from notifications
64
72
%s
65
73
order by created desc
66
-
limit ? offset ?
67
-
`, whereClause)
68
-
69
-
args = append(args, page.Limit, page.Offset)
74
+
%s
75
+
`, whereClause, pageClause)
70
76
71
77
rows, err := e.QueryContext(context.Background(), query, args...)
72
78
if err != nil {
···
128
134
select
129
135
n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
130
136
n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
131
-
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description,
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,
132
138
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,
133
139
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
134
140
from notifications n
···
157
163
var issue models.Issue
158
164
var pull models.Pull
159
165
var rId, iId, pId sql.NullInt64
160
-
var rDid, rName, rDescription sql.NullString
166
+
var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString
161
167
var iDid sql.NullString
162
168
var iIssueId sql.NullInt64
163
169
var iTitle sql.NullString
···
170
176
err := rows.Scan(
171
177
&n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
172
178
&n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
173
-
&rId, &rDid, &rName, &rDescription,
179
+
&rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr,
174
180
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
175
181
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
176
182
)
···
198
204
if rDescription.Valid {
199
205
repo.Description = rDescription.String
200
206
}
207
+
if rWebsite.Valid {
208
+
repo.Website = rWebsite.String
209
+
}
210
+
if rTopicStr.Valid {
211
+
repo.Topics = strings.Fields(rTopicStr.String)
212
+
}
201
213
nwe.Repo = &repo
202
214
}
203
215
···
248
260
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
249
261
}
250
262
251
-
// GetNotifications retrieves notifications for a user with pagination (legacy method for backward compatibility)
252
-
func (d *DB) GetNotifications(ctx context.Context, userDID string, limit, offset int) ([]*models.Notification, error) {
253
-
page := pagination.Page{Limit: limit, Offset: offset}
254
-
return GetNotificationsPaginated(d.DB, page, FilterEq("recipient_did", userDID))
255
-
}
256
-
257
-
// GetNotificationsWithEntities retrieves notifications with entities for a user with pagination
258
-
func (d *DB) GetNotificationsWithEntities(ctx context.Context, userDID string, limit, offset int) ([]*models.NotificationWithEntity, error) {
259
-
page := pagination.Page{Limit: limit, Offset: offset}
260
-
return GetNotificationsWithEntities(d.DB, page, FilterEq("recipient_did", userDID))
261
-
}
262
-
263
-
func (d *DB) GetUnreadNotificationCount(ctx context.Context, userDID string) (int, error) {
264
-
recipientFilter := FilterEq("recipient_did", userDID)
265
-
readFilter := FilterEq("read", 0)
263
+
func CountNotifications(e Execer, filters ...filter) (int64, error) {
264
+
var conditions []string
265
+
var args []any
266
+
for _, filter := range filters {
267
+
conditions = append(conditions, filter.Condition())
268
+
args = append(args, filter.Arg()...)
269
+
}
266
270
267
-
query := fmt.Sprintf(`
268
-
SELECT COUNT(*)
269
-
FROM notifications
270
-
WHERE %s AND %s
271
-
`, recipientFilter.Condition(), readFilter.Condition())
271
+
whereClause := ""
272
+
if conditions != nil {
273
+
whereClause = " where " + strings.Join(conditions, " and ")
274
+
}
272
275
273
-
args := append(recipientFilter.Arg(), readFilter.Arg()...)
276
+
query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause)
277
+
var count int64
278
+
err := e.QueryRow(query, args...).Scan(&count)
274
279
275
-
var count int
276
-
err := d.DB.QueryRowContext(ctx, query, args...).Scan(&count)
277
-
if err != nil {
278
-
return 0, fmt.Errorf("failed to get unread count: %w", err)
280
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
281
+
return 0, err
279
282
}
280
283
281
284
return count, nil
282
285
}
283
286
284
-
func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error {
287
+
func MarkNotificationRead(e Execer, notificationID int64, userDID string) error {
285
288
idFilter := FilterEq("id", notificationID)
286
289
recipientFilter := FilterEq("recipient_did", userDID)
287
290
···
293
296
294
297
args := append(idFilter.Arg(), recipientFilter.Arg()...)
295
298
296
-
result, err := d.DB.ExecContext(ctx, query, args...)
299
+
result, err := e.Exec(query, args...)
297
300
if err != nil {
298
301
return fmt.Errorf("failed to mark notification as read: %w", err)
299
302
}
···
310
313
return nil
311
314
}
312
315
313
-
func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error {
316
+
func MarkAllNotificationsRead(e Execer, userDID string) error {
314
317
recipientFilter := FilterEq("recipient_did", userDID)
315
318
readFilter := FilterEq("read", 0)
316
319
···
322
325
323
326
args := append(recipientFilter.Arg(), readFilter.Arg()...)
324
327
325
-
_, err := d.DB.ExecContext(ctx, query, args...)
328
+
_, err := e.Exec(query, args...)
326
329
if err != nil {
327
330
return fmt.Errorf("failed to mark all notifications as read: %w", err)
328
331
}
···
330
333
return nil
331
334
}
332
335
333
-
func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error {
336
+
func DeleteNotification(e Execer, notificationID int64, userDID string) error {
334
337
idFilter := FilterEq("id", notificationID)
335
338
recipientFilter := FilterEq("recipient_did", userDID)
336
339
···
341
344
342
345
args := append(idFilter.Arg(), recipientFilter.Arg()...)
343
346
344
-
result, err := d.DB.ExecContext(ctx, query, args...)
347
+
result, err := e.Exec(query, args...)
345
348
if err != nil {
346
349
return fmt.Errorf("failed to delete notification: %w", err)
347
350
}
···
358
361
return nil
359
362
}
360
363
361
-
func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) {
362
-
userFilter := FilterEq("user_did", userDID)
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
+
}
363
369
364
-
query := fmt.Sprintf(`
365
-
SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created,
366
-
pull_commented, followed, pull_merged, issue_closed, email_notifications
367
-
FROM notification_preferences
368
-
WHERE %s
369
-
`, userFilter.Condition())
370
+
p, ok := prefs[syntax.DID(userDid)]
371
+
if !ok {
372
+
return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil
373
+
}
370
374
371
-
var prefs models.NotificationPreferences
372
-
err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan(
373
-
&prefs.ID,
374
-
&prefs.UserDid,
375
-
&prefs.RepoStarred,
376
-
&prefs.IssueCreated,
377
-
&prefs.IssueCommented,
378
-
&prefs.PullCreated,
379
-
&prefs.PullCommented,
380
-
&prefs.Followed,
381
-
&prefs.PullMerged,
382
-
&prefs.IssueClosed,
383
-
&prefs.EmailNotifications,
384
-
)
375
+
return p, nil
376
+
}
377
+
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)
385
411
412
+
rows, err := e.Query(query, args...)
386
413
if err != nil {
387
-
if err == sql.ErrNoRows {
388
-
return &models.NotificationPreferences{
389
-
UserDid: userDID,
390
-
RepoStarred: true,
391
-
IssueCreated: true,
392
-
IssueCommented: true,
393
-
PullCreated: true,
394
-
PullCommented: true,
395
-
Followed: true,
396
-
PullMerged: true,
397
-
IssueClosed: true,
398
-
EmailNotifications: false,
399
-
}, 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
400
435
}
401
-
return nil, fmt.Errorf("failed to get notification preferences: %w", err)
436
+
437
+
prefsMap[prefs.UserDid] = &prefs
438
+
}
439
+
440
+
if err := rows.Err(); err != nil {
441
+
return nil, err
402
442
}
403
443
404
-
return &prefs, nil
444
+
return prefsMap, nil
405
445
}
406
446
407
447
func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
408
448
query := `
409
449
INSERT OR REPLACE INTO notification_preferences
410
450
(user_did, repo_starred, issue_created, issue_commented, pull_created,
411
-
pull_commented, followed, pull_merged, issue_closed, email_notifications)
412
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
451
+
pull_commented, followed, user_mentioned, pull_merged, issue_closed,
452
+
email_notifications)
453
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
413
454
`
414
455
415
456
result, err := d.DB.ExecContext(ctx, query,
···
420
461
prefs.PullCreated,
421
462
prefs.PullCommented,
422
463
prefs.Followed,
464
+
prefs.UserMentioned,
423
465
prefs.PullMerged,
424
466
prefs.IssueClosed,
425
467
prefs.EmailNotifications,
+26
-6
appview/db/profile.go
+26
-6
appview/db/profile.go
···
129
129
did,
130
130
description,
131
131
include_bluesky,
132
-
location
132
+
location,
133
+
pronouns
133
134
)
134
-
values (?, ?, ?, ?)`,
135
+
values (?, ?, ?, ?, ?)`,
135
136
profile.Did,
136
137
profile.Description,
137
138
includeBskyValue,
138
139
profile.Location,
140
+
profile.Pronouns,
139
141
)
140
142
141
143
if err != nil {
···
216
218
did,
217
219
description,
218
220
include_bluesky,
219
-
location
221
+
location,
222
+
pronouns
220
223
from
221
224
profile
222
225
%s`,
···
231
234
for rows.Next() {
232
235
var profile models.Profile
233
236
var includeBluesky int
237
+
var pronouns sql.Null[string]
234
238
235
-
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
239
+
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns)
236
240
if err != nil {
237
241
return nil, err
238
242
}
239
243
240
244
if includeBluesky != 0 {
241
245
profile.IncludeBluesky = true
246
+
}
247
+
248
+
if pronouns.Valid {
249
+
profile.Pronouns = pronouns.V
242
250
}
243
251
244
252
profileMap[profile.Did] = &profile
···
302
310
303
311
func GetProfile(e Execer, did string) (*models.Profile, error) {
304
312
var profile models.Profile
313
+
var pronouns sql.Null[string]
314
+
305
315
profile.Did = did
306
316
307
317
includeBluesky := 0
318
+
308
319
err := e.QueryRow(
309
-
`select description, include_bluesky, location from profile where did = ?`,
320
+
`select description, include_bluesky, location, pronouns from profile where did = ?`,
310
321
did,
311
-
).Scan(&profile.Description, &includeBluesky, &profile.Location)
322
+
).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns)
312
323
if err == sql.ErrNoRows {
313
324
profile := models.Profile{}
314
325
profile.Did = did
···
321
332
322
333
if includeBluesky != 0 {
323
334
profile.IncludeBluesky = true
335
+
}
336
+
337
+
if pronouns.Valid {
338
+
profile.Pronouns = pronouns.V
324
339
}
325
340
326
341
rows, err := e.Query(`select link from profile_links where did = ?`, did)
···
412
427
// ensure description is not too long
413
428
if len(profile.Location) > 40 {
414
429
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.")
415
435
}
416
436
417
437
// ensure links are in order
+201
-221
appview/db/pulls.go
+201
-221
appview/db/pulls.go
···
1
1
package db
2
2
3
3
import (
4
+
"cmp"
4
5
"database/sql"
6
+
"errors"
5
7
"fmt"
6
-
"log"
8
+
"maps"
9
+
"slices"
7
10
"sort"
8
11
"strings"
9
12
"time"
···
87
90
pull.ID = int(id)
88
91
89
92
_, err = tx.Exec(`
90
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
93
+
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
91
94
values (?, ?, ?, ?, ?)
92
-
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
95
+
`, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
93
96
return err
94
97
}
95
98
···
98
101
if err != nil {
99
102
return "", err
100
103
}
101
-
return pull.PullAt(), err
104
+
return pull.AtUri(), err
102
105
}
103
106
104
107
func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) {
···
108
111
}
109
112
110
113
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
111
-
pulls := make(map[int]*models.Pull)
114
+
pulls := make(map[syntax.ATURI]*models.Pull)
112
115
113
116
var conditions []string
114
117
var args []any
···
211
214
pull.ParentChangeId = parentChangeId.String
212
215
}
213
216
214
-
pulls[pull.PullId] = &pull
217
+
pulls[pull.AtUri()] = &pull
215
218
}
216
219
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
220
+
var pullAts []syntax.ATURI
230
221
for _, p := range pulls {
231
-
args[idx] = p.RepoAt
232
-
idx += 1
222
+
pullAts = append(pullAts, p.AtUri())
233
223
}
234
-
for _, p := range pulls {
235
-
args[idx] = p.PullId
236
-
idx += 1
237
-
}
238
-
submissionsRows, err := e.Query(submissionsQuery, args...)
224
+
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
239
225
if err != nil {
240
-
return nil, err
226
+
return nil, fmt.Errorf("failed to get submissions: %w", err)
241
227
}
242
-
defer submissionsRows.Close()
243
228
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
229
+
for pullAt, submissions := range submissionsMap {
230
+
if p, ok := pulls[pullAt]; ok {
231
+
p.Submissions = submissions
258
232
}
233
+
}
259
234
260
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
261
-
if err != nil {
262
-
return nil, err
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)
239
+
}
240
+
for pullAt, labels := range allLabels {
241
+
if p, ok := pulls[pullAt]; ok {
242
+
p.Labels = labels
263
243
}
264
-
s.Created = createdTime
244
+
}
265
245
266
-
if sourceRev.Valid {
267
-
s.SourceRev = sourceRev.String
246
+
// collect pull source for all pulls that need it
247
+
var sourceAts []syntax.ATURI
248
+
for _, p := range pulls {
249
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
250
+
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
268
251
}
269
-
270
-
if p, ok := pulls[s.PullId]; ok {
271
-
p.Submissions = make([]*models.PullSubmission, s.RoundNumber+1)
272
-
p.Submissions[s.RoundNumber] = &s
273
-
}
252
+
}
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)
274
256
}
275
-
if err := rows.Err(); err != nil {
276
-
return nil, err
257
+
sourceRepoMap := make(map[syntax.ATURI]*models.Repo)
258
+
for _, r := range sourceRepos {
259
+
sourceRepoMap[r.RepoAt()] = &r
277
260
}
278
-
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{}
293
261
for _, p := range pulls {
294
-
args = append(args, p.Submissions[p.LastRoundNumber()].ID)
295
-
}
296
-
commentsRows, err := e.Query(commentsQuery, args...)
297
-
if err != nil {
298
-
return nil, err
299
-
}
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)
262
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
263
+
if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok {
264
+
p.PullSource.Repo = sourceRepo
265
+
}
313
266
}
314
267
}
315
-
if err := rows.Err(); err != nil {
316
-
return nil, err
317
-
}
318
268
319
269
orderedByPullId := []*models.Pull{}
320
270
for _, p := range pulls {
···
331
281
return GetPullsWithLimit(e, 0, filters...)
332
282
}
333
283
334
-
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
335
-
query := `
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
+
`
336
316
select
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
317
+
id
352
318
from
353
319
pulls
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,
320
+
%s
321
+
%s`,
322
+
whereClause,
323
+
pageClause,
378
324
)
325
+
args = append(args, opts.Page.Limit, opts.Page.Offset)
326
+
rows, err := e.Query(query, args...)
379
327
if err != nil {
380
328
return nil, err
329
+
}
330
+
defer rows.Close()
331
+
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)
381
340
}
382
341
383
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
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))
384
347
if err != nil {
385
348
return nil, err
386
349
}
387
-
pull.Created = createdTime
350
+
if len(pulls) == 0 {
351
+
return nil, sql.ErrNoRows
352
+
}
388
353
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
-
}
401
-
}
354
+
return pulls[0], nil
355
+
}
402
356
403
-
if stackId.Valid {
404
-
pull.StackId = stackId.String
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()...)
405
364
}
406
-
if changeId.Valid {
407
-
pull.ChangeId = changeId.String
408
-
}
409
-
if parentChangeId.Valid {
410
-
pull.ParentChangeId = parentChangeId.String
365
+
366
+
whereClause := ""
367
+
if conditions != nil {
368
+
whereClause = " where " + strings.Join(conditions, " and ")
411
369
}
412
370
413
-
submissionsQuery := `
371
+
query := fmt.Sprintf(`
414
372
select
415
-
id, pull_id, repo_at, round_number, patch, created, source_rev
373
+
id,
374
+
pull_at,
375
+
round_number,
376
+
patch,
377
+
combined,
378
+
created,
379
+
source_rev
416
380
from
417
381
pull_submissions
418
-
where
419
-
repo_at = ? and pull_id = ?
420
-
`
421
-
submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId)
382
+
%s
383
+
order by
384
+
round_number asc
385
+
`, whereClause)
386
+
387
+
rows, err := e.Query(query, args...)
422
388
if err != nil {
423
389
return nil, err
424
390
}
425
-
defer submissionsRows.Close()
391
+
defer rows.Close()
426
392
427
-
submissionsMap := make(map[int]*models.PullSubmission)
393
+
submissionMap := make(map[int]*models.PullSubmission)
428
394
429
-
for submissionsRows.Next() {
395
+
for rows.Next() {
430
396
var submission models.PullSubmission
431
397
var submissionCreatedStr string
432
-
var submissionSourceRev sql.NullString
433
-
err := submissionsRows.Scan(
398
+
var submissionSourceRev, submissionCombined sql.NullString
399
+
err := rows.Scan(
434
400
&submission.ID,
435
-
&submission.PullId,
436
-
&submission.RepoAt,
401
+
&submission.PullAt,
437
402
&submission.RoundNumber,
438
403
&submission.Patch,
404
+
&submissionCombined,
439
405
&submissionCreatedStr,
440
406
&submissionSourceRev,
441
407
)
···
443
409
return nil, err
444
410
}
445
411
446
-
submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr)
447
-
if err != nil {
448
-
return nil, err
412
+
if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil {
413
+
submission.Created = t
449
414
}
450
-
submission.Created = submissionCreatedTime
451
415
452
416
if submissionSourceRev.Valid {
453
417
submission.SourceRev = submissionSourceRev.String
454
418
}
455
419
456
-
submissionsMap[submission.ID] = &submission
420
+
if submissionCombined.Valid {
421
+
submission.Combined = submissionCombined.String
422
+
}
423
+
424
+
submissionMap[submission.ID] = &submission
425
+
}
426
+
427
+
if err := rows.Err(); err != nil {
428
+
return nil, err
457
429
}
458
-
if err = submissionsRows.Close(); err != nil {
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 {
459
435
return nil, err
460
436
}
461
-
if len(submissionsMap) == 0 {
462
-
return &pull, nil
437
+
for _, comment := range comments {
438
+
if submission, ok := submissionMap[comment.SubmissionId]; ok {
439
+
submission.Comments = append(submission.Comments, comment)
440
+
}
441
+
}
442
+
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
+
})
463
454
}
464
455
456
+
return m, nil
457
+
}
458
+
459
+
func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) {
460
+
var conditions []string
465
461
var args []any
466
-
for k := range submissionsMap {
467
-
args = append(args, k)
462
+
for _, filter := range filters {
463
+
conditions = append(conditions, filter.Condition())
464
+
args = append(args, filter.Arg()...)
468
465
}
469
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ")
470
-
commentsQuery := fmt.Sprintf(`
466
+
467
+
whereClause := ""
468
+
if conditions != nil {
469
+
whereClause = " where " + strings.Join(conditions, " and ")
470
+
}
471
+
472
+
query := fmt.Sprintf(`
471
473
select
472
474
id,
473
475
pull_id,
···
479
481
created
480
482
from
481
483
pull_comments
482
-
where
483
-
submission_id IN (%s)
484
+
%s
484
485
order by
485
486
created asc
486
-
`, inClause)
487
-
commentsRows, err := e.Query(commentsQuery, args...)
487
+
`, whereClause)
488
+
489
+
rows, err := e.Query(query, args...)
488
490
if err != nil {
489
491
return nil, err
490
492
}
491
-
defer commentsRows.Close()
493
+
defer rows.Close()
492
494
493
-
for commentsRows.Next() {
495
+
var comments []models.PullComment
496
+
for rows.Next() {
494
497
var comment models.PullComment
495
-
var commentCreatedStr string
496
-
err := commentsRows.Scan(
498
+
var createdAt string
499
+
err := rows.Scan(
497
500
&comment.ID,
498
501
&comment.PullId,
499
502
&comment.SubmissionId,
···
501
504
&comment.OwnerDid,
502
505
&comment.CommentAt,
503
506
&comment.Body,
504
-
&commentCreatedStr,
507
+
&createdAt,
505
508
)
506
509
if err != nil {
507
510
return nil, err
508
511
}
509
512
510
-
commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr)
511
-
if err != nil {
512
-
return nil, err
513
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
514
+
comment.Created = t
513
515
}
514
-
comment.Created = commentCreatedTime
515
516
516
-
// Add the comment to its submission
517
-
if submission, ok := submissionsMap[comment.SubmissionId]; ok {
518
-
submission.Comments = append(submission.Comments, comment)
519
-
}
517
+
comments = append(comments, comment)
518
+
}
520
519
521
-
}
522
-
if err = commentsRows.Err(); err != nil {
520
+
if err := rows.Err(); err != nil {
523
521
return nil, err
524
522
}
525
523
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
524
+
return comments, nil
544
525
}
545
526
546
527
// timeframe here is directly passed into the sql query filter, and any
···
674
655
return err
675
656
}
676
657
677
-
func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error {
678
-
newRoundNumber := len(pull.Submissions)
658
+
func ResubmitPull(e Execer, pullAt syntax.ATURI, newRoundNumber int, newPatch string, combinedPatch string, newSourceRev string) error {
679
659
_, err := e.Exec(`
680
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
660
+
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
681
661
values (?, ?, ?, ?, ?)
682
-
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
662
+
`, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
683
663
684
664
return err
685
665
}
+34
-7
appview/db/reaction.go
+34
-7
appview/db/reaction.go
···
62
62
return count, nil
63
63
}
64
64
65
-
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) {
66
-
countMap := map[models.ReactionKind]int{}
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{}
67
81
for _, kind := range models.OrderedReactionKinds {
68
-
count, err := GetReactionCount(e, threadAt, kind)
69
-
if err != nil {
70
-
return map[models.ReactionKind]int{}, nil
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
71
91
}
72
-
countMap[kind] = count
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
73
99
}
74
-
return countMap, nil
100
+
101
+
return reactionMap, rows.Err()
75
102
}
76
103
77
104
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+50
-12
appview/db/repos.go
+50
-12
appview/db/repos.go
···
70
70
rkey,
71
71
created,
72
72
description,
73
+
website,
74
+
topics,
73
75
source,
74
76
spindle
75
77
from
···
89
91
for rows.Next() {
90
92
var repo models.Repo
91
93
var createdAt string
92
-
var description, source, spindle sql.NullString
94
+
var description, website, topicStr, source, spindle sql.NullString
93
95
94
96
err := rows.Scan(
95
97
&repo.Id,
···
99
101
&repo.Rkey,
100
102
&createdAt,
101
103
&description,
104
+
&website,
105
+
&topicStr,
102
106
&source,
103
107
&spindle,
104
108
)
···
111
115
}
112
116
if description.Valid {
113
117
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)
114
124
}
115
125
if source.Valid {
116
126
repo.Source = source.String
···
356
366
func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) {
357
367
var repo models.Repo
358
368
var nullableDescription sql.NullString
369
+
var nullableWebsite sql.NullString
370
+
var nullableTopicStr sql.NullString
359
371
360
-
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
372
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri)
361
373
362
374
var createdAt string
363
-
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
375
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil {
364
376
return nil, err
365
377
}
366
378
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
368
380
369
381
if nullableDescription.Valid {
370
382
repo.Description = nullableDescription.String
371
-
} else {
372
-
repo.Description = ""
383
+
}
384
+
if nullableWebsite.Valid {
385
+
repo.Website = nullableWebsite.String
386
+
}
387
+
if nullableTopicStr.Valid {
388
+
repo.Topics = strings.Fields(nullableTopicStr.String)
373
389
}
374
390
375
391
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
376
403
}
377
404
378
405
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
379
406
_, err := tx.Exec(
380
407
`insert into repos
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,
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,
384
411
)
385
412
if err != nil {
386
413
return fmt.Errorf("failed to insert repo: %w", err)
···
416
443
var repos []models.Repo
417
444
418
445
rows, err := e.Query(
419
-
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
446
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source
420
447
from repos r
421
448
left join collaborators c on r.at_uri = c.repo_at
422
449
where (r.did = ? or c.subject_did = ?)
···
434
461
var repo models.Repo
435
462
var createdAt string
436
463
var nullableDescription sql.NullString
464
+
var nullableWebsite sql.NullString
437
465
var nullableSource sql.NullString
438
466
439
-
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
467
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource)
440
468
if err != nil {
441
469
return nil, err
442
470
}
···
470
498
var repo models.Repo
471
499
var createdAt string
472
500
var nullableDescription sql.NullString
501
+
var nullableWebsite sql.NullString
502
+
var nullableTopicStr sql.NullString
473
503
var nullableSource sql.NullString
474
504
475
505
row := e.QueryRow(
476
-
`select id, did, name, knot, rkey, description, created, source
506
+
`select id, did, name, knot, rkey, description, website, topics, created, source
477
507
from repos
478
508
where did = ? and name = ? and source is not null and source != ''`,
479
509
did, name,
480
510
)
481
511
482
-
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
512
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource)
483
513
if err != nil {
484
514
return nil, err
485
515
}
486
516
487
517
if nullableDescription.Valid {
488
518
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)
489
527
}
490
528
491
529
if nullableSource.Valid {
+38
-10
appview/db/timeline.go
+38
-10
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) ([]models.TimelineEvent, error) {
12
+
func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) {
13
13
var events []models.TimelineEvent
14
14
15
-
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
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)
16
29
if err != nil {
17
30
return nil, err
18
31
}
19
32
20
-
stars, err := getTimelineStars(e, limit, loggedInUserDid)
33
+
stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing)
21
34
if err != nil {
22
35
return nil, err
23
36
}
24
37
25
-
follows, err := getTimelineFollows(e, limit, loggedInUserDid)
38
+
follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing)
26
39
if err != nil {
27
40
return nil, err
28
41
}
···
70
83
return isStarred, starCount
71
84
}
72
85
73
-
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
74
-
repos, err := GetRepos(e, limit)
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...)
75
93
if err != nil {
76
94
return nil, err
77
95
}
···
125
143
return events, nil
126
144
}
127
145
128
-
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
129
-
stars, err := GetStars(e, limit)
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...)
130
153
if err != nil {
131
154
return nil, err
132
155
}
···
166
189
return events, nil
167
190
}
168
191
169
-
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
170
-
follows, err := GetFollows(e, limit)
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...)
171
199
if err != nil {
172
200
return nil, err
173
201
}
+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) error {
34
-
_, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
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{
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 nil
44
+
return result.ID, 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
+
}
+7
-1
appview/ingester.go
+7
-1
appview/ingester.go
···
89
89
}
90
90
91
91
if err != nil {
92
-
l.Debug("error ingesting record", "err", err)
92
+
l.Warn("refused to ingest 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
+
294
299
location := ""
295
300
if record.Location != nil {
296
301
location = *record.Location
···
325
330
Links: links,
326
331
Stats: stats,
327
332
PinnedRepos: pinned,
333
+
Pronouns: pronouns,
328
334
}
329
335
330
336
ddb, ok := i.Db.Execer.(*db.DB)
+113
-54
appview/issues/issues.go
+113
-54
appview/issues/issues.go
···
5
5
"database/sql"
6
6
"errors"
7
7
"fmt"
8
-
"log"
9
8
"log/slog"
10
9
"net/http"
11
10
"slices"
12
11
"time"
13
12
14
13
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"
22
23
"tangled.org/core/appview/models"
23
24
"tangled.org/core/appview/notify"
24
25
"tangled.org/core/appview/oauth"
25
26
"tangled.org/core/appview/pages"
27
+
"tangled.org/core/appview/pages/markup"
26
28
"tangled.org/core/appview/pagination"
27
29
"tangled.org/core/appview/reporesolver"
28
30
"tangled.org/core/appview/validator"
29
-
"tangled.org/core/appview/xrpcclient"
30
31
"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
45
46
}
46
47
47
48
func New(
···
53
54
config *config.Config,
54
55
notifier notify.Notifier,
55
56
validator *validator.Validator,
57
+
indexer *issues_indexer.Indexer,
58
+
logger *slog.Logger,
56
59
) *Issues {
57
60
return &Issues{
58
61
oauth: oauth,
···
62
65
db: db,
63
66
config: config,
64
67
notifier: notifier,
65
-
logger: tlog.New("issues"),
68
+
logger: logger,
66
69
validator: validator,
70
+
indexer: indexer,
67
71
}
68
72
}
69
73
···
72
76
user := rp.oauth.GetUser(r)
73
77
f, err := rp.repoResolver.Resolve(r)
74
78
if err != nil {
75
-
log.Println("failed to get repo and knot", err)
79
+
l.Error("failed to get repo and knot", "err", err)
76
80
return
77
81
}
78
82
···
83
87
return
84
88
}
85
89
86
-
reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
90
+
reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri())
87
91
if err != nil {
88
92
l.Error("failed to get issue reactions", "err", err)
89
93
}
···
99
103
db.FilterContains("scope", tangled.RepoIssueNSID),
100
104
)
101
105
if err != nil {
102
-
log.Println("failed to fetch labels", err)
106
+
l.Error("failed to fetch labels", "err", err)
103
107
rp.pages.Error503(w)
104
108
return
105
109
}
···
115
119
Issue: issue,
116
120
CommentList: issue.CommentList(),
117
121
OrderedReactionKinds: models.OrderedReactionKinds,
118
-
Reactions: reactionCountMap,
122
+
Reactions: reactionMap,
119
123
UserReacted: userReactions,
120
124
LabelDefs: defs,
121
125
})
···
126
130
user := rp.oauth.GetUser(r)
127
131
f, err := rp.repoResolver.Resolve(r)
128
132
if err != nil {
129
-
log.Println("failed to get repo and knot", err)
133
+
l.Error("failed to get repo and knot", "err", err)
130
134
return
131
135
}
132
136
···
166
170
return
167
171
}
168
172
169
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
173
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
170
174
if err != nil {
171
175
l.Error("failed to get record", "err", err)
172
176
rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
173
177
return
174
178
}
175
179
176
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
180
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
177
181
Collection: tangled.RepoIssueNSID,
178
182
Repo: user.Did,
179
183
Rkey: newIssue.Rkey,
···
199
203
200
204
err = db.PutIssue(tx, newIssue)
201
205
if err != nil {
202
-
log.Println("failed to edit issue", err)
206
+
l.Error("failed to edit issue", "err", err)
203
207
rp.pages.Notice(w, "issues", "Failed to edit issue.")
204
208
return
205
209
}
···
237
241
// delete from PDS
238
242
client, err := rp.oauth.AuthorizedClient(r)
239
243
if err != nil {
240
-
log.Println("failed to get authorized client", err)
244
+
l.Error("failed to get authorized client", "err", err)
241
245
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
242
246
return
243
247
}
244
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
248
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
245
249
Collection: tangled.RepoIssueNSID,
246
250
Repo: issue.Did,
247
251
Rkey: issue.Rkey,
···
260
264
return
261
265
}
262
266
267
+
rp.notifier.DeleteIssue(r.Context(), issue)
268
+
263
269
// return to all issues page
264
270
rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
265
271
}
···
282
288
283
289
collaborators, err := f.Collaborators(r.Context())
284
290
if err != nil {
285
-
log.Println("failed to fetch repo collaborators: %w", err)
291
+
l.Error("failed to fetch repo collaborators", "err", err)
286
292
}
287
293
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
288
294
return user.Did == collab.Did
···
296
302
db.FilterEq("id", issue.Id),
297
303
)
298
304
if err != nil {
299
-
log.Println("failed to close issue", err)
305
+
l.Error("failed to close issue", "err", err)
300
306
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
301
307
return
302
308
}
309
+
// change the issue state (this will pass down to the notifiers)
310
+
issue.Open = false
303
311
304
312
// notify about the issue closure
305
-
rp.notifier.NewIssueClosed(r.Context(), issue)
313
+
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
306
314
307
315
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
308
316
return
309
317
} else {
310
-
log.Println("user is not permitted to close issue")
318
+
l.Error("user is not permitted to close issue")
311
319
http.Error(w, "for biden", http.StatusUnauthorized)
312
320
return
313
321
}
···
318
326
user := rp.oauth.GetUser(r)
319
327
f, err := rp.repoResolver.Resolve(r)
320
328
if err != nil {
321
-
log.Println("failed to get repo and knot", err)
329
+
l.Error("failed to get repo and knot", "err", err)
322
330
return
323
331
}
324
332
···
331
339
332
340
collaborators, err := f.Collaborators(r.Context())
333
341
if err != nil {
334
-
log.Println("failed to fetch repo collaborators: %w", err)
342
+
l.Error("failed to fetch repo collaborators", "err", err)
335
343
}
336
344
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
337
345
return user.Did == collab.Did
···
344
352
db.FilterEq("id", issue.Id),
345
353
)
346
354
if err != nil {
347
-
log.Println("failed to reopen issue", err)
355
+
l.Error("failed to reopen issue", "err", err)
348
356
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
349
357
return
350
358
}
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
+
351
365
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
352
366
return
353
367
} else {
354
-
log.Println("user is not the owner of the repo")
368
+
l.Error("user is not the owner of the repo")
355
369
http.Error(w, "forbidden", http.StatusUnauthorized)
356
370
return
357
371
}
···
408
422
}
409
423
410
424
// create a record first
411
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
425
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
412
426
Collection: tangled.RepoIssueCommentNSID,
413
427
Repo: comment.Did,
414
428
Rkey: comment.Rkey,
···
440
454
441
455
// notify about the new comment
442
456
comment.Id = commentId
443
-
rp.notifier.NewIssueComment(r.Context(), &comment)
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)
444
468
445
469
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
446
470
}
···
538
562
newBody := r.FormValue("body")
539
563
client, err := rp.oauth.AuthorizedClient(r)
540
564
if err != nil {
541
-
log.Println("failed to get authorized client", err)
565
+
l.Error("failed to get authorized client", "err", err)
542
566
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
543
567
return
544
568
}
···
551
575
552
576
_, err = db.AddIssueComment(rp.db, newComment)
553
577
if err != nil {
554
-
log.Println("failed to perferom update-description query", err)
578
+
l.Error("failed to perferom update-description query", "err", err)
555
579
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
556
580
return
557
581
}
···
559
583
// rkey is optional, it was introduced later
560
584
if newComment.Rkey != "" {
561
585
// update the record on pds
562
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
586
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
563
587
if err != nil {
564
-
log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
588
+
l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
565
589
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
566
590
return
567
591
}
568
592
569
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
593
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
570
594
Collection: tangled.RepoIssueCommentNSID,
571
595
Repo: user.Did,
572
596
Rkey: newComment.Rkey,
···
729
753
if comment.Rkey != "" {
730
754
client, err := rp.oauth.AuthorizedClient(r)
731
755
if err != nil {
732
-
log.Println("failed to get authorized client", err)
756
+
l.Error("failed to get authorized client", "err", err)
733
757
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
734
758
return
735
759
}
736
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
760
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
737
761
Collection: tangled.RepoIssueCommentNSID,
738
762
Repo: user.Did,
739
763
Rkey: comment.Rkey,
740
764
})
741
765
if err != nil {
742
-
log.Println(err)
766
+
l.Error("failed to delete from PDS", "err", err)
743
767
}
744
768
}
745
769
···
757
781
}
758
782
759
783
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
784
+
l := rp.logger.With("handler", "RepoIssues")
785
+
760
786
params := r.URL.Query()
761
787
state := params.Get("state")
762
788
isOpen := true
···
769
795
isOpen = true
770
796
}
771
797
772
-
page, ok := r.Context().Value("page").(pagination.Page)
773
-
if !ok {
774
-
log.Println("failed to get page")
775
-
page = pagination.FirstPage()
776
-
}
798
+
page := pagination.FromContext(r.Context())
777
799
778
800
user := rp.oauth.GetUser(r)
779
801
f, err := rp.repoResolver.Resolve(r)
780
802
if err != nil {
781
-
log.Println("failed to get repo and knot", err)
803
+
l.Error("failed to get repo and knot", "err", err)
782
804
return
783
805
}
784
806
785
-
openVal := 0
786
-
if isOpen {
787
-
openVal = 1
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,
788
815
}
789
-
issues, err := db.GetIssuesPaginated(
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(
790
834
rp.db,
791
-
page,
792
-
db.FilterEq("repo_at", f.RepoAt()),
793
-
db.FilterEq("open", openVal),
835
+
db.FilterIn("id", ids),
794
836
)
795
837
if err != nil {
796
-
log.Println("failed to get issues", err)
838
+
l.Error("failed to get issues", "err", err)
797
839
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
798
840
return
799
841
}
800
842
801
-
labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
843
+
labelDefs, err := db.GetLabelDefinitions(
844
+
rp.db,
845
+
db.FilterIn("at_uri", f.Repo.Labels),
846
+
db.FilterContains("scope", tangled.RepoIssueNSID),
847
+
)
802
848
if err != nil {
803
-
log.Println("failed to fetch labels", err)
849
+
l.Error("failed to fetch labels", "err", err)
804
850
rp.pages.Error503(w)
805
851
return
806
852
}
···
816
862
Issues: issues,
817
863
LabelDefs: defs,
818
864
FilteringByOpen: isOpen,
865
+
FilterQuery: keyword,
819
866
Page: page,
820
867
})
821
868
}
···
842
889
Rkey: tid.TID(),
843
890
Title: r.FormValue("title"),
844
891
Body: r.FormValue("body"),
892
+
Open: true,
845
893
Did: user.Did,
846
894
Created: time.Now(),
895
+
Repo: &f.Repo,
847
896
}
848
897
849
898
if err := rp.validator.ValidateIssue(issue); err != nil {
···
861
910
rp.pages.Notice(w, "issues", "Failed to create issue.")
862
911
return
863
912
}
864
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
913
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
865
914
Collection: tangled.RepoIssueNSID,
866
915
Repo: user.Did,
867
916
Rkey: issue.Rkey,
···
897
946
898
947
err = db.PutIssue(tx, issue)
899
948
if err != nil {
900
-
log.Println("failed to create issue", err)
949
+
l.Error("failed to create issue", "err", err)
901
950
rp.pages.Notice(w, "issues", "Failed to create issue.")
902
951
return
903
952
}
904
953
905
954
if err = tx.Commit(); err != nil {
906
-
log.Println("failed to create issue", err)
955
+
l.Error("failed to create issue", "err", err)
907
956
rp.pages.Notice(w, "issues", "Failed to create issue.")
908
957
return
909
958
}
910
959
911
960
// everything is successful, do not rollback the atproto record
912
961
atUri = ""
913
-
rp.notifier.NewIssue(r.Context(), issue)
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)
914
973
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
915
974
return
916
975
}
···
919
978
// this is used to rollback changes made to the PDS
920
979
//
921
980
// it is a no-op if the provided ATURI is empty
922
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
981
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
923
982
if aturi == "" {
924
983
return nil
925
984
}
···
930
989
repo := parsed.Authority().String()
931
990
rkey := parsed.RecordKey().String()
932
991
933
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
992
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
934
993
Collection: collection,
935
994
Repo: repo,
936
995
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
+15
-6
appview/knots/knots.go
+15
-6
appview/knots/knots.go
···
6
6
"log/slog"
7
7
"net/http"
8
8
"slices"
9
+
"strings"
9
10
"time"
10
11
11
12
"github.com/go-chi/chi/v5"
···
145
146
}
146
147
147
148
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, "/")
148
155
if domain == "" {
149
156
k.Pages.Notice(w, noticeId, "Incomplete form.")
150
157
return
···
185
192
return
186
193
}
187
194
188
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
195
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain)
189
196
var exCid *string
190
197
if ex != nil {
191
198
exCid = ex.Cid
192
199
}
193
200
194
201
// re-announce by registering under same rkey
195
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
202
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
196
203
Collection: tangled.KnotNSID,
197
204
Repo: user.Did,
198
205
Rkey: domain,
···
323
330
return
324
331
}
325
332
326
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
333
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
327
334
Collection: tangled.KnotNSID,
328
335
Repo: user.Did,
329
336
Rkey: domain,
···
431
438
return
432
439
}
433
440
434
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
441
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain)
435
442
var exCid *string
436
443
if ex != nil {
437
444
exCid = ex.Cid
438
445
}
439
446
440
447
// ignore the error here
441
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
448
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
442
449
Collection: tangled.KnotNSID,
443
450
Repo: user.Did,
444
451
Rkey: domain,
···
526
533
}
527
534
528
535
member := r.FormValue("member")
536
+
member = strings.TrimPrefix(member, "@")
529
537
if member == "" {
530
538
l.Error("empty member")
531
539
k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
555
563
556
564
rkey := tid.TID()
557
565
558
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
566
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
559
567
Collection: tangled.KnotMemberNSID,
560
568
Repo: user.Did,
561
569
Rkey: rkey,
···
626
634
}
627
635
628
636
member := r.FormValue("member")
637
+
member = strings.TrimPrefix(member, "@")
629
638
if member == "" {
630
639
l.Error("empty member")
631
640
k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+11
-13
appview/labels/labels.go
+11
-13
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
-
17
12
"tangled.org/core/api/tangled"
18
13
"tangled.org/core/appview/db"
19
14
"tangled.org/core/appview/middleware"
···
21
16
"tangled.org/core/appview/oauth"
22
17
"tangled.org/core/appview/pages"
23
18
"tangled.org/core/appview/validator"
24
-
"tangled.org/core/appview/xrpcclient"
25
-
"tangled.org/core/log"
26
19
"tangled.org/core/rbac"
27
20
"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"
28
27
)
29
28
30
29
type Labels struct {
···
42
41
db *db.DB,
43
42
validator *validator.Validator,
44
43
enforcer *rbac.Enforcer,
44
+
logger *slog.Logger,
45
45
) *Labels {
46
-
logger := log.New("labels")
47
-
48
46
return &Labels{
49
47
oauth: oauth,
50
48
pages: pages,
···
55
53
}
56
54
}
57
55
58
-
func (l *Labels) Router(mw *middleware.Middleware) http.Handler {
56
+
func (l *Labels) Router() http.Handler {
59
57
r := chi.NewRouter()
60
58
61
59
r.Use(middleware.AuthMiddleware(l.oauth))
···
196
194
return
197
195
}
198
196
199
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
197
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
200
198
Collection: tangled.LabelOpNSID,
201
199
Repo: did,
202
200
Rkey: rkey,
···
252
250
// this is used to rollback changes made to the PDS
253
251
//
254
252
// it is a no-op if the provided ATURI is empty
255
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
253
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
256
254
if aturi == "" {
257
255
return nil
258
256
}
···
263
261
repo := parsed.Authority().String()
264
262
rkey := parsed.RecordKey().String()
265
263
266
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
264
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
267
265
Collection: collection,
268
266
Repo: repo,
269
267
Rkey: rkey,
+16
-30
appview/middleware/middleware.go
+16
-30
appview/middleware/middleware.go
···
43
43
44
44
type middlewareFunc func(http.Handler) http.Handler
45
45
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 {
46
+
func AuthMiddleware(o *oauth.OAuth) middlewareFunc {
56
47
return func(next http.Handler) http.Handler {
57
48
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58
49
returnURL := "/"
···
72
63
}
73
64
}
74
65
75
-
_, auth, err := a.GetSession(r)
66
+
sess, err := o.ResumeSession(r)
76
67
if err != nil {
77
-
log.Println("not logged in, redirecting", "err", err)
68
+
log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String())
78
69
redirectFunc(w, r)
79
70
return
80
71
}
81
72
82
-
if !auth {
83
-
log.Printf("not logged in, redirecting")
73
+
if sess == nil {
74
+
log.Printf("session is nil, redirecting...")
84
75
redirectFunc(w, r)
85
76
return
86
77
}
···
114
105
}
115
106
}
116
107
117
-
ctx := context.WithValue(r.Context(), "page", page)
108
+
ctx := pagination.IntoContext(r.Context(), page)
118
109
next.ServeHTTP(w, r.WithContext(ctx))
119
110
})
120
111
}
···
189
180
return func(next http.Handler) http.Handler {
190
181
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
191
182
didOrHandle := chi.URLParam(req, "user")
183
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
184
+
192
185
if slices.Contains(excluded, didOrHandle) {
193
186
next.ServeHTTP(w, req)
194
187
return
195
188
}
196
189
197
-
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
198
-
199
190
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
200
191
if err != nil {
201
192
// invalid did or handle
···
215
206
return func(next http.Handler) http.Handler {
216
207
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
217
208
repoName := chi.URLParam(req, "repo")
209
+
repoName = strings.TrimSuffix(repoName, ".git")
210
+
218
211
id, ok := req.Context().Value("resolvedId").(identity.Identity)
219
212
if !ok {
220
213
log.Println("malformed middleware")
···
253
246
prId := chi.URLParam(r, "pull")
254
247
prIdInt, err := strconv.Atoi(prId)
255
248
if err != nil {
256
-
http.Error(w, "bad pr id", http.StatusBadRequest)
257
249
log.Println("failed to parse pr id", err)
250
+
mw.pages.Error404(w)
258
251
return
259
252
}
260
253
261
254
pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt)
262
255
if err != nil {
263
256
log.Println("failed to get pull and comments", err)
257
+
mw.pages.Error404(w)
264
258
return
265
259
}
266
260
···
301
295
issueId, err := strconv.Atoi(issueIdStr)
302
296
if err != nil {
303
297
log.Println("failed to fully resolve issue ID", err)
304
-
mw.pages.ErrorKnot404(w)
298
+
mw.pages.Error404(w)
305
299
return
306
300
}
307
301
308
-
issues, err := db.GetIssues(
309
-
mw.db,
310
-
db.FilterEq("repo_at", f.RepoAt()),
311
-
db.FilterEq("issue_id", issueId),
312
-
)
302
+
issue, err := db.GetIssue(mw.db, f.RepoAt(), issueId)
313
303
if err != nil {
314
304
log.Println("failed to get issues", "err", err)
305
+
mw.pages.Error404(w)
315
306
return
316
307
}
317
-
if len(issues) != 1 {
318
-
log.Println("got incorrect number of issues", "len(issuse)", len(issues))
319
-
return
320
-
}
321
-
issue := issues[0]
322
308
323
-
ctx := context.WithValue(r.Context(), "issue", &issue)
309
+
ctx := context.WithValue(r.Context(), "issue", issue)
324
310
next.ServeHTTP(w, r.WithContext(ctx))
325
311
})
326
312
}
+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
+
57
77
func (i *Issue) CommentList() []CommentListItem {
58
78
// Create a map to quickly find comments by their aturi
59
79
toplevel := make(map[string]*CommentListItem)
···
167
187
168
188
func (i *IssueComment) IsTopLevel() bool {
169
189
return i.ReplyTo == nil
190
+
}
191
+
192
+
func (i *IssueComment) IsReply() bool {
193
+
return i.ReplyTo != nil
170
194
}
171
195
172
196
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+25
-42
appview/models/label.go
+25
-42
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"
18
17
"tangled.org/core/idresolver"
19
18
)
20
19
···
461
460
return result
462
461
}
463
462
464
-
func DefaultLabelDefs() []string {
465
-
rkeys := []string{
466
-
"wontfix",
467
-
"duplicate",
468
-
"assignee",
469
-
"good-first-issue",
470
-
"documentation",
471
-
}
472
-
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
-
}
477
-
478
-
return defs
479
-
}
480
-
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
-
}
493
-
463
+
func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) {
494
464
var labelDefs []LabelDefinition
465
+
ctx := context.Background()
495
466
496
-
for _, dl := range DefaultLabelDefs() {
497
-
atUri := syntax.ATURI(dl)
498
-
parsedUri, err := syntax.ParseATURI(string(atUri))
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
+
}
475
+
476
+
owner, err := r.ResolveIdent(ctx, atUri.Authority().String())
499
477
if err != nil {
500
-
return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err)
478
+
return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err)
479
+
}
480
+
481
+
xrpcc := xrpc.Client{
482
+
Host: owner.PDSEndpoint(),
501
483
}
484
+
502
485
record, err := atproto.RepoGetRecord(
503
-
context.Background(),
504
-
client,
486
+
ctx,
487
+
&xrpcc,
505
488
"",
506
-
parsedUri.Collection().String(),
507
-
parsedUri.Authority().String(),
508
-
parsedUri.RecordKey().String(),
489
+
atUri.Collection().String(),
490
+
atUri.Authority().String(),
491
+
atUri.RecordKey().String(),
509
492
)
510
493
if err != nil {
511
494
return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
···
525
508
}
526
509
527
510
labelDef, err := LabelDefinitionFromRecord(
528
-
parsedUri.Authority().String(),
529
-
parsedUri.RecordKey().String(),
511
+
atUri.Authority().String(),
512
+
atUri.RecordKey().String(),
530
513
labelRecord,
531
514
)
532
515
if err != nil {
+89
-2
appview/models/notifications.go
+89
-2
appview/models/notifications.go
···
1
1
package models
2
2
3
-
import "time"
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
4
8
5
9
type NotificationType string
6
10
···
13
17
NotificationTypeFollowed NotificationType = "followed"
14
18
NotificationTypePullMerged NotificationType = "pull_merged"
15
19
NotificationTypeIssueClosed NotificationType = "issue_closed"
20
+
NotificationTypeIssueReopen NotificationType = "issue_reopen"
16
21
NotificationTypePullClosed NotificationType = "pull_closed"
22
+
NotificationTypePullReopen NotificationType = "pull_reopen"
23
+
NotificationTypeUserMentioned NotificationType = "user_mentioned"
17
24
)
18
25
19
26
type Notification struct {
···
32
39
PullId *int64
33
40
}
34
41
42
+
// lucide icon that represents this notification
43
+
func (n *Notification) Icon() string {
44
+
switch n.Type {
45
+
case NotificationTypeRepoStarred:
46
+
return "star"
47
+
case NotificationTypeIssueCreated:
48
+
return "circle-dot"
49
+
case NotificationTypeIssueCommented:
50
+
return "message-square"
51
+
case NotificationTypeIssueClosed:
52
+
return "ban"
53
+
case NotificationTypeIssueReopen:
54
+
return "circle-dot"
55
+
case NotificationTypePullCreated:
56
+
return "git-pull-request-create"
57
+
case NotificationTypePullCommented:
58
+
return "message-square"
59
+
case NotificationTypePullMerged:
60
+
return "git-merge"
61
+
case NotificationTypePullClosed:
62
+
return "git-pull-request-closed"
63
+
case NotificationTypePullReopen:
64
+
return "git-pull-request-create"
65
+
case NotificationTypeFollowed:
66
+
return "user-plus"
67
+
case NotificationTypeUserMentioned:
68
+
return "at-sign"
69
+
default:
70
+
return ""
71
+
}
72
+
}
73
+
35
74
type NotificationWithEntity struct {
36
75
*Notification
37
76
Repo *Repo
···
41
80
42
81
type NotificationPreferences struct {
43
82
ID int64
44
-
UserDid string
83
+
UserDid syntax.DID
45
84
RepoStarred bool
46
85
IssueCreated bool
47
86
IssueCommented bool
48
87
PullCreated bool
49
88
PullCommented bool
50
89
Followed bool
90
+
UserMentioned bool
51
91
PullMerged bool
52
92
IssueClosed bool
53
93
EmailNotifications bool
54
94
}
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
+77
-28
appview/models/pull.go
+77
-28
appview/models/pull.go
···
77
77
PullSource *PullSource
78
78
79
79
// optionally, populate this when querying for reverse mappings
80
-
Repo *Repo
80
+
Labels LabelState
81
+
Repo *Repo
81
82
}
82
83
83
84
func (p Pull) AsRecord() tangled.RepoPull {
84
85
var source *tangled.RepoPull_Source
85
86
if p.PullSource != nil {
86
-
s := p.PullSource.AsRecord()
87
-
source = &s
87
+
source = &tangled.RepoPull_Source{}
88
+
source.Branch = p.PullSource.Branch
88
89
source.Sha = p.LatestSha()
90
+
if p.PullSource.RepoAt != nil {
91
+
s := p.PullSource.RepoAt.String()
92
+
source.Repo = &s
93
+
}
89
94
}
90
95
91
96
record := tangled.RepoPull{
···
110
115
Repo *Repo
111
116
}
112
117
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
-
126
118
type PullSubmission struct {
127
119
// ids
128
-
ID int
129
-
PullId int
120
+
ID int
130
121
131
122
// at ids
132
-
RepoAt syntax.ATURI
123
+
PullAt syntax.ATURI
133
124
134
125
// content
135
126
RoundNumber int
136
127
Patch string
128
+
Combined string
137
129
Comments []PullComment
138
130
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
139
131
···
159
151
Created time.Time
160
152
}
161
153
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
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
164
-
return latestSubmission.Patch
163
+
return p.LatestSubmission().Patch
165
164
}
166
165
167
166
func (p *Pull) LatestSha() string {
168
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
169
-
return latestSubmission.SourceRev
167
+
return p.LatestSubmission().SourceRev
170
168
}
171
169
172
-
func (p *Pull) PullAt() syntax.ATURI {
170
+
func (p *Pull) AtUri() syntax.ATURI {
173
171
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
178
172
}
179
173
180
174
func (p *Pull) IsPatchBased() bool {
···
207
201
return p.StackId != ""
208
202
}
209
203
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
+
210
226
func (s PullSubmission) IsFormatPatch() bool {
211
227
return patchutil.IsFormatPatch(s.Patch)
212
228
}
···
219
235
}
220
236
221
237
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
222
266
}
223
267
224
268
type Stack []*Pull
···
308
352
309
353
return mergeable
310
354
}
355
+
356
+
type BranchDeleteStatus struct {
357
+
Repo *Repo
358
+
Branch string
359
+
}
+5
appview/models/reaction.go
+5
appview/models/reaction.go
+19
-1
appview/models/repo.go
+19
-1
appview/models/repo.go
···
2
2
3
3
import (
4
4
"fmt"
5
+
"strings"
5
6
"time"
6
7
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
···
17
18
Rkey string
18
19
Created time.Time
19
20
Description string
21
+
Website string
22
+
Topics []string
20
23
Spindle string
21
24
Labels []string
22
25
···
28
31
}
29
32
30
33
func (r *Repo) AsRecord() tangled.Repo {
31
-
var source, spindle, description *string
34
+
var source, spindle, description, website *string
32
35
33
36
if r.Source != "" {
34
37
source = &r.Source
···
42
45
description = &r.Description
43
46
}
44
47
48
+
if r.Website != "" {
49
+
website = &r.Website
50
+
}
51
+
45
52
return tangled.Repo{
46
53
Knot: r.Knot,
47
54
Name: r.Name,
48
55
Description: description,
56
+
Website: website,
57
+
Topics: r.Topics,
49
58
CreatedAt: r.Created.Format(time.RFC3339),
50
59
Source: source,
51
60
Spindle: spindle,
···
60
69
func (r Repo) DidSlashRepo() string {
61
70
p, _ := securejoin.SecureJoin(r.Did, r.Name)
62
71
return p
72
+
}
73
+
74
+
func (r Repo) TopicStr() string {
75
+
return strings.Join(r.Topics, " ")
63
76
}
64
77
65
78
type RepoStats struct {
···
86
99
RepoAt syntax.ATURI
87
100
LabelAt syntax.ATURI
88
101
}
102
+
103
+
type RepoGroup struct {
104
+
Repo *Repo
105
+
Issues []Issue
106
+
}
+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
+
// }
+54
-62
appview/notifications/notifications.go
+54
-62
appview/notifications/notifications.go
···
1
1
package notifications
2
2
3
3
import (
4
-
"log"
4
+
"log/slog"
5
5
"net/http"
6
6
"strconv"
7
7
···
10
10
"tangled.org/core/appview/middleware"
11
11
"tangled.org/core/appview/oauth"
12
12
"tangled.org/core/appview/pages"
13
+
"tangled.org/core/appview/pagination"
13
14
)
14
15
15
16
type Notifications struct {
16
-
db *db.DB
17
-
oauth *oauth.OAuth
18
-
pages *pages.Pages
17
+
db *db.DB
18
+
oauth *oauth.OAuth
19
+
pages *pages.Pages
20
+
logger *slog.Logger
19
21
}
20
22
21
-
func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications {
23
+
func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications {
22
24
return &Notifications{
23
-
db: database,
24
-
oauth: oauthHandler,
25
-
pages: pagesHandler,
25
+
db: database,
26
+
oauth: oauthHandler,
27
+
pages: pagesHandler,
28
+
logger: logger,
26
29
}
27
30
}
28
31
29
32
func (n *Notifications) Router(mw *middleware.Middleware) http.Handler {
30
33
r := chi.NewRouter()
31
34
32
-
r.Use(middleware.AuthMiddleware(n.oauth))
33
-
34
-
r.Get("/", n.notificationsPage)
35
-
36
35
r.Get("/count", n.getUnreadCount)
37
-
r.Post("/{id}/read", n.markRead)
38
-
r.Post("/read-all", n.markAllRead)
39
-
r.Delete("/{id}", n.deleteNotification)
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
+
})
40
44
41
45
return r
42
46
}
43
47
44
48
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
45
-
userDid := n.oauth.GetDid(r)
49
+
l := n.logger.With("handler", "notificationsPage")
50
+
user := n.oauth.GetUser(r)
46
51
47
-
limitStr := r.URL.Query().Get("limit")
48
-
offsetStr := r.URL.Query().Get("offset")
52
+
page := pagination.FromContext(r.Context())
49
53
50
-
limit := 20 // default
51
-
if limitStr != "" {
52
-
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
53
-
limit = l
54
-
}
55
-
}
56
-
57
-
offset := 0 // default
58
-
if offsetStr != "" {
59
-
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
60
-
offset = o
61
-
}
62
-
}
63
-
64
-
notifications, err := n.db.GetNotificationsWithEntities(r.Context(), userDid, limit+1, offset)
54
+
total, err := db.CountNotifications(
55
+
n.db,
56
+
db.FilterEq("recipient_did", user.Did),
57
+
)
65
58
if err != nil {
66
-
log.Println("failed to get notifications:", err)
59
+
l.Error("failed to get total notifications", "err", err)
67
60
n.pages.Error500(w)
68
61
return
69
62
}
70
63
71
-
hasMore := len(notifications) > limit
72
-
if hasMore {
73
-
notifications = notifications[:limit]
64
+
notifications, err := db.GetNotificationsWithEntities(
65
+
n.db,
66
+
page,
67
+
db.FilterEq("recipient_did", user.Did),
68
+
)
69
+
if err != nil {
70
+
l.Error("failed to get notifications", "err", err)
71
+
n.pages.Error500(w)
72
+
return
74
73
}
75
74
76
-
err = n.db.MarkAllNotificationsRead(r.Context(), userDid)
75
+
err = db.MarkAllNotificationsRead(n.db, user.Did)
77
76
if err != nil {
78
-
log.Println("failed to mark notifications as read:", err)
77
+
l.Error("failed to mark notifications as read", "err", err)
79
78
}
80
79
81
80
unreadCount := 0
82
81
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
-
params := pages.NotificationsParams{
82
+
n.pages.Notifications(w, pages.NotificationsParams{
90
83
LoggedInUser: user,
91
84
Notifications: notifications,
92
85
UnreadCount: unreadCount,
93
-
HasMore: hasMore,
94
-
NextOffset: offset + limit,
95
-
Limit: limit,
96
-
}
86
+
Page: page,
87
+
Total: total,
88
+
})
89
+
}
97
90
98
-
err = n.pages.Notifications(w, params)
99
-
if err != nil {
100
-
log.Println("failed to load notifs:", err)
101
-
n.pages.Error500(w)
91
+
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
92
+
user := n.oauth.GetUser(r)
93
+
if user == nil {
102
94
return
103
95
}
104
-
}
105
96
106
-
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
107
-
userDid := n.oauth.GetDid(r)
108
-
109
-
count, err := n.db.GetUnreadNotificationCount(r.Context(), userDid)
97
+
count, err := db.CountNotifications(
98
+
n.db,
99
+
db.FilterEq("recipient_did", user.Did),
100
+
db.FilterEq("read", 0),
101
+
)
110
102
if err != nil {
111
103
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
112
104
return
···
132
124
return
133
125
}
134
126
135
-
err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid)
127
+
err = db.MarkNotificationRead(n.db, notificationID, userDid)
136
128
if err != nil {
137
129
http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError)
138
130
return
···
144
136
func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) {
145
137
userDid := n.oauth.GetDid(r)
146
138
147
-
err := n.db.MarkAllNotificationsRead(r.Context(), userDid)
139
+
err := db.MarkAllNotificationsRead(n.db, userDid)
148
140
if err != nil {
149
141
http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError)
150
142
return
···
163
155
return
164
156
}
165
157
166
-
err = n.db.DeleteNotification(r.Context(), notificationID, userDid)
158
+
err = db.DeleteNotification(n.db, notificationID, userDid)
167
159
if err != nil {
168
160
http.Error(w, "Failed to delete notification", http.StatusInternalServerError)
169
161
return
+322
-302
appview/notify/db/db.go
+322
-302
appview/notify/db/db.go
···
3
3
import (
4
4
"context"
5
5
"log"
6
+
"maps"
7
+
"slices"
6
8
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
10
"tangled.org/core/appview/db"
8
11
"tangled.org/core/appview/models"
9
12
"tangled.org/core/appview/notify"
10
13
"tangled.org/core/idresolver"
14
+
)
15
+
16
+
const (
17
+
maxMentions = 5
11
18
)
12
19
13
20
type databaseNotifier struct {
···
30
37
31
38
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
32
39
var err error
33
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(star.RepoAt)))
40
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
34
41
if err != nil {
35
42
log.Printf("NewStar: failed to get repos: %v", err)
36
43
return
37
44
}
38
-
if len(repos) == 0 {
39
-
log.Printf("NewStar: no repo found for %s", star.RepoAt)
40
-
return
41
-
}
42
-
repo := repos[0]
43
45
44
-
// don't notify yourself
45
-
if repo.Did == star.StarredByDid {
46
-
return
47
-
}
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
48
54
49
-
// check if user wants these notifications
50
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
51
-
if err != nil {
52
-
log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err)
53
-
return
54
-
}
55
-
if !prefs.RepoStarred {
56
-
return
57
-
}
58
-
59
-
notification := &models.Notification{
60
-
RecipientDid: repo.Did,
61
-
ActorDid: star.StarredByDid,
62
-
Type: models.NotificationTypeRepoStarred,
63
-
EntityType: "repo",
64
-
EntityId: string(star.RepoAt),
65
-
RepoId: &repo.Id,
66
-
}
67
-
err = n.db.CreateNotification(ctx, notification)
68
-
if err != nil {
69
-
log.Printf("NewStar: failed to create notification: %v", err)
70
-
return
71
-
}
55
+
n.notifyEvent(
56
+
actorDid,
57
+
recipients,
58
+
eventType,
59
+
entityType,
60
+
entityId,
61
+
repoId,
62
+
issueId,
63
+
pullId,
64
+
)
72
65
}
73
66
74
67
func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {
75
68
// no-op
76
69
}
77
70
78
-
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
79
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
80
-
if err != nil {
81
-
log.Printf("NewIssue: failed to get repos: %v", err)
82
-
return
83
-
}
84
-
if len(repos) == 0 {
85
-
log.Printf("NewIssue: no repo found for %s", issue.RepoAt)
86
-
return
87
-
}
88
-
repo := repos[0]
71
+
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
89
72
90
-
if repo.Did == issue.Did {
91
-
return
92
-
}
93
-
94
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
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()))
95
79
if err != nil {
96
-
log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err)
80
+
log.Printf("failed to fetch collaborators: %v", err)
97
81
return
98
82
}
99
-
if !prefs.IssueCreated {
100
-
return
83
+
for _, c := range collaborators {
84
+
recipients = append(recipients, c.SubjectDid)
101
85
}
102
86
103
-
notification := &models.Notification{
104
-
RecipientDid: repo.Did,
105
-
ActorDid: issue.Did,
106
-
Type: models.NotificationTypeIssueCreated,
107
-
EntityType: "issue",
108
-
EntityId: string(issue.AtUri()),
109
-
RepoId: &repo.Id,
110
-
IssueId: &issue.Id,
111
-
}
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
112
93
113
-
err = n.db.CreateNotification(ctx, notification)
114
-
if err != nil {
115
-
log.Printf("NewIssue: failed to create notification: %v", err)
116
-
return
117
-
}
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
+
)
118
114
}
119
115
120
-
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
116
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
121
117
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
122
118
if err != nil {
123
119
log.Printf("NewIssueComment: failed to get issues: %v", err)
···
129
125
}
130
126
issue := issues[0]
131
127
132
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
133
-
if err != nil {
134
-
log.Printf("NewIssueComment: failed to get repos: %v", err)
135
-
return
136
-
}
137
-
if len(repos) == 0 {
138
-
log.Printf("NewIssueComment: no repo found for %s", issue.RepoAt)
139
-
return
140
-
}
141
-
repo := repos[0]
128
+
var recipients []syntax.DID
129
+
recipients = append(recipients, syntax.DID(issue.Repo.Did))
142
130
143
-
recipients := make(map[string]bool)
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()
144
135
145
-
// notify issue author (if not the commenter)
146
-
if issue.Did != comment.Did {
147
-
prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did)
148
-
if err == nil && prefs.IssueCommented {
149
-
recipients[issue.Did] = true
150
-
} else if err != nil {
151
-
log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err)
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
+
}
152
141
}
142
+
} else {
143
+
// not a reply, notify just the issue author
144
+
recipients = append(recipients, syntax.DID(issue.Did))
153
145
}
154
146
155
-
// notify repo owner (if not the commenter and not already added)
156
-
if repo.Did != comment.Did && repo.Did != issue.Did {
157
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
158
-
if err == nil && prefs.IssueCommented {
159
-
recipients[repo.Did] = true
160
-
} else if err != nil {
161
-
log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
162
-
}
163
-
}
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
164
153
165
-
// create notifications for all recipients
166
-
for recipientDid := range recipients {
167
-
notification := &models.Notification{
168
-
RecipientDid: recipientDid,
169
-
ActorDid: comment.Did,
170
-
Type: models.NotificationTypeIssueCommented,
171
-
EntityType: "issue",
172
-
EntityId: string(issue.AtUri()),
173
-
RepoId: &repo.Id,
174
-
IssueId: &issue.Id,
175
-
}
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
+
}
176
175
177
-
err = n.db.CreateNotification(ctx, notification)
178
-
if err != nil {
179
-
log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err)
180
-
}
181
-
}
176
+
func (n *databaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {
177
+
// no-op for now
182
178
}
183
179
184
180
func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
185
-
prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid)
186
-
if err != nil {
187
-
log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err)
188
-
return
189
-
}
190
-
if !prefs.Followed {
191
-
return
192
-
}
193
-
194
-
notification := &models.Notification{
195
-
RecipientDid: follow.SubjectDid,
196
-
ActorDid: follow.UserDid,
197
-
Type: models.NotificationTypeFollowed,
198
-
EntityType: "follow",
199
-
EntityId: follow.UserDid,
200
-
}
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
201
187
202
-
err = n.db.CreateNotification(ctx, notification)
203
-
if err != nil {
204
-
log.Printf("NewFollow: failed to create notification: %v", err)
205
-
return
206
-
}
188
+
n.notifyEvent(
189
+
actorDid,
190
+
recipients,
191
+
eventType,
192
+
entityType,
193
+
entityId,
194
+
repoId,
195
+
issueId,
196
+
pullId,
197
+
)
207
198
}
208
199
209
200
func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
···
211
202
}
212
203
213
204
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
214
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
205
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
215
206
if err != nil {
216
207
log.Printf("NewPull: failed to get repos: %v", err)
217
208
return
218
209
}
219
-
if len(repos) == 0 {
220
-
log.Printf("NewPull: no repo found for %s", pull.RepoAt)
221
-
return
222
-
}
223
-
repo := repos[0]
224
210
225
-
if repo.Did == pull.OwnerDid {
226
-
return
227
-
}
228
-
229
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
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()))
230
217
if err != nil {
231
-
log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err)
218
+
log.Printf("failed to fetch collaborators: %v", err)
232
219
return
233
220
}
234
-
if !prefs.PullCreated {
235
-
return
221
+
for _, c := range collaborators {
222
+
recipients = append(recipients, c.SubjectDid)
236
223
}
237
224
238
-
notification := &models.Notification{
239
-
RecipientDid: repo.Did,
240
-
ActorDid: pull.OwnerDid,
241
-
Type: models.NotificationTypePullCreated,
242
-
EntityType: "pull",
243
-
EntityId: string(pull.RepoAt),
244
-
RepoId: &repo.Id,
245
-
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
246
-
}
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
247
233
248
-
err = n.db.CreateNotification(ctx, notification)
249
-
if err != nil {
250
-
log.Printf("NewPull: failed to create notification: %v", err)
251
-
return
252
-
}
234
+
n.notifyEvent(
235
+
actorDid,
236
+
recipients,
237
+
eventType,
238
+
entityType,
239
+
entityId,
240
+
repoId,
241
+
issueId,
242
+
pullId,
243
+
)
253
244
}
254
245
255
-
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
256
-
pulls, err := db.GetPulls(n.db,
257
-
db.FilterEq("repo_at", comment.RepoAt),
258
-
db.FilterEq("pull_id", comment.PullId))
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
+
)
259
251
if err != nil {
260
252
log.Printf("NewPullComment: failed to get pulls: %v", err)
261
253
return
262
254
}
263
-
if len(pulls) == 0 {
264
-
log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId)
265
-
return
266
-
}
267
-
pull := pulls[0]
268
255
269
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", comment.RepoAt))
256
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
270
257
if err != nil {
271
258
log.Printf("NewPullComment: failed to get repos: %v", err)
272
259
return
273
260
}
274
-
if len(repos) == 0 {
275
-
log.Printf("NewPullComment: no repo found for %s", comment.RepoAt)
276
-
return
277
-
}
278
-
repo := repos[0]
279
261
280
-
recipients := make(map[string]bool)
281
-
282
-
// notify pull request author (if not the commenter)
283
-
if pull.OwnerDid != comment.OwnerDid {
284
-
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
285
-
if err == nil && prefs.PullCommented {
286
-
recipients[pull.OwnerDid] = true
287
-
} else if err != nil {
288
-
log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err)
289
-
}
290
-
}
291
-
292
-
// notify repo owner (if not the commenter and not already added)
293
-
if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid {
294
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
295
-
if err == nil && prefs.PullCommented {
296
-
recipients[repo.Did] = true
297
-
} else if err != nil {
298
-
log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
299
-
}
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))
300
269
}
301
270
302
-
for recipientDid := range recipients {
303
-
notification := &models.Notification{
304
-
RecipientDid: recipientDid,
305
-
ActorDid: comment.OwnerDid,
306
-
Type: models.NotificationTypePullCommented,
307
-
EntityType: "pull",
308
-
EntityId: comment.RepoAt,
309
-
RepoId: &repo.Id,
310
-
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
311
-
}
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
312
279
313
-
err = n.db.CreateNotification(ctx, notification)
314
-
if err != nil {
315
-
log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err)
316
-
}
317
-
}
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
+
)
318
300
}
319
301
320
302
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
···
333
315
// no-op
334
316
}
335
317
336
-
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
337
-
// Get repo details
338
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
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()))
339
326
if err != nil {
340
-
log.Printf("NewIssueClosed: failed to get repos: %v", err)
327
+
log.Printf("failed to fetch collaborators: %v", err)
341
328
return
342
329
}
343
-
if len(repos) == 0 {
344
-
log.Printf("NewIssueClosed: no repo found for %s", issue.RepoAt)
345
-
return
330
+
for _, c := range collaborators {
331
+
recipients = append(recipients, c.SubjectDid)
346
332
}
347
-
repo := repos[0]
348
-
349
-
// Don't notify yourself
350
-
if repo.Did == issue.Did {
351
-
return
333
+
for _, p := range issue.Participants() {
334
+
recipients = append(recipients, syntax.DID(p))
352
335
}
353
336
354
-
// Check if user wants these notifications
355
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
356
-
if err != nil {
357
-
log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err)
358
-
return
359
-
}
360
-
if !prefs.IssueClosed {
361
-
return
362
-
}
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
363
343
364
-
notification := &models.Notification{
365
-
RecipientDid: repo.Did,
366
-
ActorDid: issue.Did,
367
-
Type: models.NotificationTypeIssueClosed,
368
-
EntityType: "issue",
369
-
EntityId: string(issue.AtUri()),
370
-
RepoId: &repo.Id,
371
-
IssueId: &issue.Id,
344
+
if issue.Open {
345
+
eventType = models.NotificationTypeIssueReopen
346
+
} else {
347
+
eventType = models.NotificationTypeIssueClosed
372
348
}
373
349
374
-
err = n.db.CreateNotification(ctx, notification)
375
-
if err != nil {
376
-
log.Printf("NewIssueClosed: failed to create notification: %v", err)
377
-
return
378
-
}
350
+
n.notifyEvent(
351
+
actor,
352
+
recipients,
353
+
eventType,
354
+
entityType,
355
+
entityId,
356
+
repoId,
357
+
issueId,
358
+
pullId,
359
+
)
379
360
}
380
361
381
-
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
362
+
func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
382
363
// Get repo details
383
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
364
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
384
365
if err != nil {
385
-
log.Printf("NewPullMerged: failed to get repos: %v", err)
386
-
return
387
-
}
388
-
if len(repos) == 0 {
389
-
log.Printf("NewPullMerged: no repo found for %s", pull.RepoAt)
366
+
log.Printf("NewPullState: failed to get repos: %v", err)
390
367
return
391
368
}
392
-
repo := repos[0]
393
369
394
-
// Don't notify yourself
395
-
if repo.Did == pull.OwnerDid {
396
-
return
397
-
}
398
-
399
-
// Check if user wants these notifications
400
-
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
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()))
401
376
if err != nil {
402
-
log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
377
+
log.Printf("failed to fetch collaborators: %v", err)
403
378
return
404
379
}
405
-
if !prefs.PullMerged {
406
-
return
380
+
for _, c := range collaborators {
381
+
recipients = append(recipients, c.SubjectDid)
407
382
}
408
-
409
-
notification := &models.Notification{
410
-
RecipientDid: pull.OwnerDid,
411
-
ActorDid: repo.Did,
412
-
Type: models.NotificationTypePullMerged,
413
-
EntityType: "pull",
414
-
EntityId: string(pull.RepoAt),
415
-
RepoId: &repo.Id,
416
-
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
383
+
for _, p := range pull.Participants() {
384
+
recipients = append(recipients, syntax.DID(p))
417
385
}
418
386
419
-
err = n.db.CreateNotification(ctx, notification)
420
-
if err != nil {
421
-
log.Printf("NewPullMerged: failed to create notification: %v", err)
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)
422
401
return
423
402
}
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
+
)
424
416
}
425
417
426
-
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
427
-
// Get repo details
428
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
429
-
if err != nil {
430
-
log.Printf("NewPullClosed: failed to get repos: %v", err)
431
-
return
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]
432
430
}
433
-
if len(repos) == 0 {
434
-
log.Printf("NewPullClosed: no repo found for %s", pull.RepoAt)
435
-
return
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
+
}
436
437
}
437
-
repo := repos[0]
438
438
439
-
// Don't notify yourself
440
-
if repo.Did == pull.OwnerDid {
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
441
445
return
442
446
}
443
447
444
-
// Check if user wants these notifications - reuse pull_merged preference for now
445
-
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
448
+
// create a transaction for bulk notification storage
449
+
tx, err := n.db.Begin()
446
450
if err != nil {
447
-
log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
451
+
// failed to start tx
448
452
return
449
453
}
450
-
if !prefs.PullMerged {
451
-
return
452
-
}
454
+
defer tx.Rollback()
453
455
454
-
notification := &models.Notification{
455
-
RecipientDid: pull.OwnerDid,
456
-
ActorDid: repo.Did,
457
-
Type: models.NotificationTypePullClosed,
458
-
EntityType: "pull",
459
-
EntityId: string(pull.RepoAt),
460
-
RepoId: &repo.Id,
461
-
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
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
+
}
479
+
480
+
if err := db.CreateNotification(tx, notif); err != nil {
481
+
log.Printf("notifyEvent: failed to create notification for %s: %v", recipientDid, err)
482
+
}
462
483
}
463
484
464
-
err = n.db.CreateNotification(ctx, notification)
465
-
if err != nil {
466
-
log.Printf("NewPullClosed: failed to create notification: %v", err)
485
+
if err := tx.Commit(); err != nil {
486
+
// failed to commit
467
487
return
468
488
}
469
489
}
+57
-59
appview/notify/merged_notifier.go
+57
-59
appview/notify/merged_notifier.go
···
2
2
3
3
import (
4
4
"context"
5
+
"log/slog"
6
+
"reflect"
7
+
"sync"
5
8
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
6
10
"tangled.org/core/appview/models"
11
+
"tangled.org/core/log"
7
12
)
8
13
9
14
type mergedNotifier struct {
10
15
notifiers []Notifier
16
+
logger *slog.Logger
11
17
}
12
18
13
-
func NewMergedNotifier(notifiers ...Notifier) Notifier {
14
-
return &mergedNotifier{notifiers}
19
+
func NewMergedNotifier(notifiers []Notifier, logger *slog.Logger) Notifier {
20
+
return &mergedNotifier{notifiers, logger}
15
21
}
16
22
17
23
var _ Notifier = &mergedNotifier{}
18
24
19
-
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
20
-
for _, notifier := range m.notifiers {
21
-
notifier.NewRepo(ctx, repo)
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)
22
41
}
42
+
wg.Wait()
43
+
}
44
+
45
+
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
46
+
m.fanout("NewRepo", ctx, repo)
23
47
}
24
48
25
49
func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) {
26
-
for _, notifier := range m.notifiers {
27
-
notifier.NewStar(ctx, star)
28
-
}
50
+
m.fanout("NewStar", ctx, star)
29
51
}
52
+
30
53
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) {
31
-
for _, notifier := range m.notifiers {
32
-
notifier.DeleteStar(ctx, star)
33
-
}
54
+
m.fanout("DeleteStar", ctx, star)
34
55
}
35
56
36
-
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
37
-
for _, notifier := range m.notifiers {
38
-
notifier.NewIssue(ctx, issue)
39
-
}
57
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
58
+
m.fanout("NewIssue", ctx, issue, mentions)
40
59
}
41
-
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
42
-
for _, notifier := range m.notifiers {
43
-
notifier.NewIssueComment(ctx, comment)
44
-
}
60
+
61
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
62
+
m.fanout("NewIssueComment", ctx, comment, mentions)
45
63
}
46
64
47
-
func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
48
-
for _, notifier := range m.notifiers {
49
-
notifier.NewIssueClosed(ctx, issue)
50
-
}
65
+
func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
66
+
m.fanout("NewIssueState", ctx, actor, issue)
67
+
}
68
+
69
+
func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {
70
+
m.fanout("DeleteIssue", ctx, issue)
51
71
}
52
72
53
73
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
54
-
for _, notifier := range m.notifiers {
55
-
notifier.NewFollow(ctx, follow)
56
-
}
74
+
m.fanout("NewFollow", ctx, follow)
57
75
}
76
+
58
77
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
59
-
for _, notifier := range m.notifiers {
60
-
notifier.DeleteFollow(ctx, follow)
61
-
}
78
+
m.fanout("DeleteFollow", ctx, follow)
62
79
}
63
80
64
81
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.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
-
}
82
+
m.fanout("NewPull", ctx, pull)
73
83
}
74
84
75
-
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
76
-
for _, notifier := range m.notifiers {
77
-
notifier.NewPullMerged(ctx, pull)
78
-
}
85
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
86
+
m.fanout("NewPullComment", ctx, comment, mentions)
79
87
}
80
88
81
-
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
82
-
for _, notifier := range m.notifiers {
83
-
notifier.NewPullClosed(ctx, pull)
84
-
}
89
+
func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
90
+
m.fanout("NewPullState", ctx, actor, pull)
85
91
}
86
92
87
93
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
88
-
for _, notifier := range m.notifiers {
89
-
notifier.UpdateProfile(ctx, profile)
90
-
}
94
+
m.fanout("UpdateProfile", ctx, profile)
91
95
}
92
96
93
-
func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) {
94
-
for _, notifier := range m.notifiers {
95
-
notifier.NewString(ctx, string)
96
-
}
97
+
func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) {
98
+
m.fanout("NewString", ctx, s)
97
99
}
98
100
99
-
func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) {
100
-
for _, notifier := range m.notifiers {
101
-
notifier.EditString(ctx, string)
102
-
}
101
+
func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) {
102
+
m.fanout("EditString", ctx, s)
103
103
}
104
104
105
105
func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) {
106
-
for _, notifier := range m.notifiers {
107
-
notifier.DeleteString(ctx, did, rkey)
108
-
}
106
+
m.fanout("DeleteString", ctx, did, rkey)
109
107
}
+16
-13
appview/notify/notifier.go
+16
-13
appview/notify/notifier.go
···
3
3
import (
4
4
"context"
5
5
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
6
7
"tangled.org/core/appview/models"
7
8
)
8
9
···
12
13
NewStar(ctx context.Context, star *models.Star)
13
14
DeleteStar(ctx context.Context, star *models.Star)
14
15
15
-
NewIssue(ctx context.Context, issue *models.Issue)
16
-
NewIssueComment(ctx context.Context, comment *models.IssueComment)
17
-
NewIssueClosed(ctx context.Context, issue *models.Issue)
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)
18
20
19
21
NewFollow(ctx context.Context, follow *models.Follow)
20
22
DeleteFollow(ctx context.Context, follow *models.Follow)
21
23
22
24
NewPull(ctx context.Context, 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)
25
+
NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID)
26
+
NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull)
26
27
27
28
UpdateProfile(ctx context.Context, profile *models.Profile)
28
29
···
41
42
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
42
43
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
43
44
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) {}
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) {}
47
50
48
51
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
49
52
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
50
53
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) {}
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) {}
55
58
56
59
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
57
60
+33
-9
appview/notify/posthog/notifier.go
+33
-9
appview/notify/posthog/notifier.go
···
4
4
"context"
5
5
"log"
6
6
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
8
"github.com/posthog/posthog-go"
8
9
"tangled.org/core/appview/models"
9
10
"tangled.org/core/appview/notify"
···
56
57
}
57
58
}
58
59
59
-
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
60
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
60
61
err := n.client.Enqueue(posthog.Capture{
61
62
DistinctId: issue.Did,
62
63
Event: "new_issue",
63
64
Properties: posthog.Properties{
64
65
"repo_at": issue.RepoAt.String(),
65
66
"issue_id": issue.IssueId,
67
+
"mentions": mentions,
66
68
},
67
69
})
68
70
if err != nil {
···
84
86
}
85
87
}
86
88
87
-
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
89
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
88
90
err := n.client.Enqueue(posthog.Capture{
89
91
DistinctId: comment.OwnerDid,
90
92
Event: "new_pull_comment",
91
93
Properties: posthog.Properties{
92
-
"repo_at": comment.RepoAt,
93
-
"pull_id": comment.PullId,
94
+
"repo_at": comment.RepoAt,
95
+
"pull_id": comment.PullId,
96
+
"mentions": mentions,
94
97
},
95
98
})
96
99
if err != nil {
···
177
180
}
178
181
}
179
182
180
-
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
183
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
181
184
err := n.client.Enqueue(posthog.Capture{
182
185
DistinctId: comment.Did,
183
186
Event: "new_issue_comment",
184
187
Properties: posthog.Properties{
185
188
"issue_at": comment.IssueAt,
189
+
"mentions": mentions,
186
190
},
187
191
})
188
192
if err != nil {
···
190
194
}
191
195
}
192
196
193
-
func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
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
+
}
194
204
err := n.client.Enqueue(posthog.Capture{
195
205
DistinctId: issue.Did,
196
-
Event: "issue_closed",
206
+
Event: event,
197
207
Properties: posthog.Properties{
198
208
"repo_at": issue.RepoAt.String(),
209
+
"actor": actor,
199
210
"issue_id": issue.IssueId,
200
211
},
201
212
})
···
204
215
}
205
216
}
206
217
207
-
func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
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
+
}
208
231
err := n.client.Enqueue(posthog.Capture{
209
232
DistinctId: pull.OwnerDid,
210
-
Event: "pull_merged",
233
+
Event: event,
211
234
Properties: posthog.Properties{
212
235
"repo_at": pull.RepoAt,
213
236
"pull_id": pull.PullId,
237
+
"actor": actor,
214
238
},
215
239
})
216
240
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
-
}
+2
-1
appview/oauth/consts.go
+2
-1
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
+
}
+136
-203
appview/oauth/oauth.go
+136
-203
appview/oauth/oauth.go
···
1
1
package oauth
2
2
3
3
import (
4
+
"errors"
4
5
"fmt"
5
-
"log"
6
+
"log/slog"
6
7
"net/http"
7
-
"net/url"
8
8
"time"
9
9
10
-
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
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"
11
16
"github.com/gorilla/sessions"
12
-
sessioncache "tangled.org/core/appview/cache/session"
17
+
"github.com/posthog/posthog-go"
13
18
"tangled.org/core/appview/config"
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"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/idresolver"
21
+
"tangled.org/core/rbac"
18
22
)
19
23
20
24
type OAuth struct {
21
-
store *sessions.CookieStore
22
-
config *config.Config
23
-
sess *sessioncache.SessionStore
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
24
36
}
25
37
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,
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"})
31
50
}
32
-
}
33
51
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)
52
+
// configure client secret
53
+
priv, err := atcrypto.ParsePrivateMultibase(config.OAuth.ClientSecret)
41
54
if err != nil {
42
-
return err
55
+
return nil, err
56
+
}
57
+
if err := oauthConfig.SetClientSecret(priv, config.OAuth.ClientKid); err != nil {
58
+
return nil, err
43
59
}
44
60
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)
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
+
})
50
69
if err != nil {
51
-
return fmt.Errorf("error saving user session: %w", err)
70
+
return nil, err
52
71
}
53
72
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),
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
65
80
}
66
81
67
-
return o.sess.SaveSession(r.Context(), session)
82
+
clientName := config.Core.AppviewName
83
+
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
68
98
}
69
99
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)
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)
103
+
if err != nil {
104
+
return err
74
105
}
75
106
76
-
did := userSession.Values[SessionDid].(string)
107
+
userSession.Values[SessionDid] = sessData.AccountDID.String()
108
+
userSession.Values[SessionPds] = sessData.HostURL
109
+
userSession.Values[SessionId] = sessData.SessionID
110
+
userSession.Values[SessionAuthenticated] = true
111
+
return userSession.Save(r, w)
112
+
}
77
113
78
-
err = o.sess.DeleteSession(r.Context(), did)
114
+
func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) {
115
+
userSession, err := o.SessStore.Get(r, SessionName)
79
116
if err != nil {
80
-
return fmt.Errorf("error deleting oauth session: %w", err)
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")
81
121
}
82
122
83
-
userSession.Options.MaxAge = -1
123
+
d := userSession.Values[SessionDid].(string)
124
+
sessDid, err := syntax.ParseDID(d)
125
+
if err != nil {
126
+
return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
127
+
}
84
128
85
-
return userSession.Save(r, w)
86
-
}
129
+
sessId := userSession.Values[SessionId].(string)
87
130
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)
131
+
clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId)
132
+
if err != nil {
133
+
return nil, fmt.Errorf("failed to resume session: %w", err)
92
134
}
93
135
94
-
did := userSession.Values[SessionDid].(string)
95
-
auth := userSession.Values[SessionAuthenticated].(bool)
136
+
return clientSess, nil
137
+
}
96
138
97
-
session, err := o.sess.GetSession(r.Context(), did)
139
+
func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error {
140
+
userSession, err := o.SessStore.Get(r, SessionName)
98
141
if err != nil {
99
-
return nil, false, fmt.Errorf("error getting oauth session: %w", err)
142
+
return fmt.Errorf("error getting user session: %w", err)
143
+
}
144
+
if userSession.IsNew {
145
+
return fmt.Errorf("no session available for user")
100
146
}
101
147
102
-
expiry, err := time.Parse(time.RFC3339, session.Expiry)
148
+
d := userSession.Values[SessionDid].(string)
149
+
sessDid, err := syntax.ParseDID(d)
103
150
if err != nil {
104
-
return nil, false, fmt.Errorf("error parsing expiry time: %w", err)
151
+
return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
105
152
}
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
-
}
111
153
112
-
self := o.ClientMetadata()
154
+
sessId := userSession.Values[SessionId].(string)
113
155
114
-
oauthClient, err := client.NewClient(
115
-
self.ClientID,
116
-
o.config.OAuth.Jwks,
117
-
self.RedirectURIs[0],
118
-
)
156
+
// delete the session
157
+
err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId)
119
158
120
-
if err != nil {
121
-
return nil, false, err
122
-
}
159
+
// remove the cookie
160
+
userSession.Options.MaxAge = -1
161
+
err2 := o.SessStore.Save(r, w, userSession)
123
162
124
-
resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk)
125
-
if err != nil {
126
-
return nil, false, err
127
-
}
128
-
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
-
}
134
-
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
-
}
141
-
142
-
return session, auth, nil
163
+
return errors.Join(err1, err2)
143
164
}
144
165
145
166
type User struct {
146
-
Handle string
147
-
Did string
148
-
Pds string
167
+
Did string
168
+
Pds string
149
169
}
150
170
151
-
func (a *OAuth) GetUser(r *http.Request) *User {
152
-
clientSession, err := a.store.Get(r, SessionName)
153
-
154
-
if err != nil || clientSession.IsNew {
171
+
func (o *OAuth) GetUser(r *http.Request) *User {
172
+
sess, err := o.ResumeSession(r)
173
+
if err != nil {
155
174
return nil
156
175
}
157
176
158
177
return &User{
159
-
Handle: clientSession.Values[SessionHandle].(string),
160
-
Did: clientSession.Values[SessionDid].(string),
161
-
Pds: clientSession.Values[SessionPds].(string),
178
+
Did: sess.Data.AccountDID.String(),
179
+
Pds: sess.Data.HostURL,
162
180
}
163
181
}
164
182
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 ""
183
+
func (o *OAuth) GetDid(r *http.Request) string {
184
+
if u := o.GetUser(r); u != nil {
185
+
return u.Did
170
186
}
171
187
172
-
return clientSession.Values[SessionDid].(string)
188
+
return ""
173
189
}
174
190
175
-
func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) {
176
-
session, auth, err := o.GetSession(r)
191
+
func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) {
192
+
session, err := o.ResumeSession(r)
177
193
if err != nil {
178
194
return nil, fmt.Errorf("error getting session: %w", err)
179
195
}
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
196
+
return session.APIClient(), nil
208
197
}
209
198
210
-
// use this to create a client to communicate with knots or spindles
211
-
//
212
199
// this is a higher level abstraction on ServerGetServiceAuth
213
200
type ServiceClientOpts struct {
214
201
service string
···
259
246
return scheme + s.service
260
247
}
261
248
262
-
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) {
249
+
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
263
250
opts := ServiceClientOpts{}
264
251
for _, o := range os {
265
252
o(&opts)
266
253
}
267
254
268
-
authorizedClient, err := o.AuthorizedClient(r)
255
+
client, err := o.AuthorizedClient(r)
269
256
if err != nil {
270
257
return nil, err
271
258
}
···
276
263
opts.exp = sixty
277
264
}
278
265
279
-
resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm)
266
+
resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm)
280
267
if err != nil {
281
268
return nil, err
282
269
}
283
270
284
-
return &indigo_xrpc.Client{
285
-
Auth: &indigo_xrpc.AuthInfo{
271
+
return &xrpc.Client{
272
+
Auth: &xrpc.AuthInfo{
286
273
AccessJwt: resp.Token,
287
274
},
288
275
Host: opts.Host(),
···
291
278
},
292
279
}, nil
293
280
}
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
+
}
+31
-13
appview/pages/funcmap.go
+31
-13
appview/pages/funcmap.go
···
17
17
"strings"
18
18
"time"
19
19
20
+
"github.com/bluesky-social/indigo/atproto/syntax"
20
21
"github.com/dustin/go-humanize"
21
22
"github.com/go-enry/go-enry/v2"
22
23
"tangled.org/core/appview/filetree"
···
38
39
"contains": func(s string, target string) bool {
39
40
return strings.Contains(s, target)
40
41
},
42
+
"stripPort": func(hostname string) string {
43
+
if strings.Contains(hostname, ":") {
44
+
return strings.Split(hostname, ":")[0]
45
+
}
46
+
return hostname
47
+
},
41
48
"mapContains": func(m any, key any) bool {
42
49
mapValue := reflect.ValueOf(m)
43
50
if mapValue.Kind() != reflect.Map {
···
57
64
return "handle.invalid"
58
65
}
59
66
60
-
return "@" + identity.Handle.String()
67
+
return identity.Handle.String()
61
68
},
62
69
"truncateAt30": func(s string) string {
63
70
if len(s) <= 30 {
···
68
75
"splitOn": func(s, sep string) []string {
69
76
return strings.Split(s, sep)
70
77
},
78
+
"string": func(v any) string {
79
+
return fmt.Sprint(v)
80
+
},
71
81
"int64": func(a int) int64 {
72
82
return int64(a)
73
83
},
···
117
127
return b
118
128
},
119
129
"didOrHandle": func(did, handle string) string {
120
-
if handle != "" {
121
-
return fmt.Sprintf("@%s", handle)
130
+
if handle != "" && handle != syntax.HandleInvalid.String() {
131
+
return handle
122
132
} else {
123
133
return did
124
134
}
···
236
246
sanitized := p.rctx.SanitizeDescription(htmlString)
237
247
return template.HTML(sanitized)
238
248
},
249
+
"trimUriScheme": func(text string) string {
250
+
text = strings.TrimPrefix(text, "https://")
251
+
text = strings.TrimPrefix(text, "http://")
252
+
return text
253
+
},
239
254
"isNil": func(t any) bool {
240
255
// returns false for other "zero" values
241
256
return t == nil
···
265
280
return nil
266
281
},
267
282
"i": func(name string, classes ...string) template.HTML {
268
-
data, err := icon(name, classes)
283
+
data, err := p.icon(name, classes)
269
284
if err != nil {
270
285
log.Printf("icon %s does not exist", name)
271
-
data, _ = icon("airplay", classes)
286
+
data, _ = p.icon("airplay", classes)
272
287
}
273
288
return template.HTML(data)
274
289
},
275
-
"cssContentHash": CssContentHash,
290
+
"cssContentHash": p.CssContentHash,
276
291
"fileTree": filetree.FileTree,
277
292
"pathEscape": func(s string) string {
278
293
return url.PathEscape(s)
···
281
296
u, _ := url.PathUnescape(s)
282
297
return u
283
298
},
284
-
299
+
"safeUrl": func(s string) template.URL {
300
+
return template.URL(s)
301
+
},
285
302
"tinyAvatar": func(handle string) string {
286
-
return p.avatarUri(handle, "tiny")
303
+
return p.AvatarUrl(handle, "tiny")
287
304
},
288
305
"fullAvatar": func(handle string) string {
289
-
return p.avatarUri(handle, "")
306
+
return p.AvatarUrl(handle, "")
290
307
},
291
308
"langColor": enry.GetColor,
292
309
"layoutSide": func() string {
···
297
314
},
298
315
299
316
"normalizeForHtmlId": func(s string) string {
300
-
// TODO: extend this to handle other cases?
301
-
return strings.ReplaceAll(s, ":", "_")
317
+
normalized := strings.ReplaceAll(s, ":", "_")
318
+
normalized = strings.ReplaceAll(normalized, ".", "_")
319
+
return normalized
302
320
},
303
321
"sshFingerprint": func(pubKey string) string {
304
322
fp, err := crypto.SSHFingerprint(pubKey)
···
310
328
}
311
329
}
312
330
313
-
func (p *Pages) avatarUri(handle, size string) string {
331
+
func (p *Pages) AvatarUrl(handle, size string) string {
314
332
handle = strings.TrimPrefix(handle, "@")
315
333
316
334
secret := p.avatar.SharedSecret
···
325
343
return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg)
326
344
}
327
345
328
-
func icon(name string, classes []string) (template.HTML, error) {
346
+
func (p *Pages) icon(name string, classes []string) (template.HTML, error) {
329
347
iconPath := filepath.Join("static", "icons", name)
330
348
331
349
if filepath.Ext(name) == "" {
+5
-2
appview/pages/funcmap_test.go
+5
-2
appview/pages/funcmap_test.go
···
2
2
3
3
import (
4
4
"html/template"
5
+
"log/slog"
6
+
"testing"
7
+
5
8
"tangled.org/core/appview/config"
6
9
"tangled.org/core/idresolver"
7
-
"testing"
8
10
)
9
11
10
12
func TestPages_funcMap(t *testing.T) {
···
13
15
// Named input parameters for receiver constructor.
14
16
config *config.Config
15
17
res *idresolver.Resolver
18
+
l *slog.Logger
16
19
want template.FuncMap
17
20
}{
18
21
// TODO: Add test cases.
19
22
}
20
23
for _, tt := range tests {
21
24
t.Run(tt.name, func(t *testing.T) {
22
-
p := NewPages(tt.config, tt.res)
25
+
p := NewPages(tt.config, tt.res, tt.l)
23
26
got := p.funcMap()
24
27
// TODO: update the condition below to compare got with tt.want.
25
28
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
+
}
+38
-2
appview/pages/markup/markdown.go
+38
-2
appview/pages/markup/markdown.go
···
5
5
"bytes"
6
6
"fmt"
7
7
"io"
8
+
"io/fs"
8
9
"net/url"
9
10
"path"
10
11
"strings"
···
20
21
"github.com/yuin/goldmark/renderer/html"
21
22
"github.com/yuin/goldmark/text"
22
23
"github.com/yuin/goldmark/util"
24
+
callout "gitlab.com/staticnoise/goldmark-callout"
23
25
htmlparse "golang.org/x/net/html"
24
26
25
27
"tangled.org/core/api/tangled"
28
+
textension "tangled.org/core/appview/pages/markup/extension"
26
29
"tangled.org/core/appview/pages/repoinfo"
27
30
)
28
31
···
45
48
IsDev bool
46
49
RendererType RendererType
47
50
Sanitizer Sanitizer
51
+
Files fs.FS
48
52
}
49
53
50
-
func (rctx *RenderContext) RenderMarkdown(source string) string {
54
+
func NewMarkdown() goldmark.Markdown {
51
55
md := goldmark.New(
52
56
goldmark.WithExtensions(
53
57
extension.GFM,
···
62
66
extension.WithFootnoteIDPrefix([]byte("footnote")),
63
67
),
64
68
treeblood.MathML(),
69
+
callout.CalloutExtention,
70
+
textension.AtExt,
65
71
),
66
72
goldmark.WithParserOptions(
67
73
parser.WithAutoHeadingID(),
68
74
),
69
75
goldmark.WithRendererOptions(html.WithUnsafe()),
70
76
)
77
+
return md
78
+
}
79
+
80
+
func (rctx *RenderContext) RenderMarkdown(source string) string {
81
+
md := NewMarkdown()
71
82
72
83
if rctx != nil {
73
84
var transformers []util.PrioritizedValue
···
140
151
func visitNode(ctx *RenderContext, node *htmlparse.Node) {
141
152
switch node.Type {
142
153
case htmlparse.ElementNode:
143
-
if node.Data == "img" || node.Data == "source" {
154
+
switch node.Data {
155
+
case "img", "source":
144
156
for i, attr := range node.Attr {
145
157
if attr.Key != "src" {
146
158
continue
···
288
300
}
289
301
290
302
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
291
327
}
292
328
293
329
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
+
80
83
// centering content
81
84
policy.AllowElements("center")
82
85
···
113
116
}
114
117
policy.AllowNoAttrs().OnElements(mathElements...)
115
118
policy.AllowAttrs(mathAttrs...).OnElements(mathElements...)
119
+
120
+
// goldmark-callout
121
+
policy.AllowAttrs("data-callout").OnElements("details")
116
122
117
123
return policy
118
124
}
+83
-59
appview/pages/pages.go
+83
-59
appview/pages/pages.go
···
15
15
"path/filepath"
16
16
"strings"
17
17
"sync"
18
+
"time"
18
19
19
20
"tangled.org/core/api/tangled"
20
21
"tangled.org/core/appview/commitverify"
···
54
55
logger *slog.Logger
55
56
}
56
57
57
-
func NewPages(config *config.Config, res *idresolver.Resolver) *Pages {
58
+
func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages {
58
59
// initialized with safe defaults, can be overriden per use
59
60
rctx := &markup.RenderContext{
60
61
IsDev: config.Core.Dev,
61
62
CamoUrl: config.Camo.Host,
62
63
CamoSecret: config.Camo.SharedSecret,
63
64
Sanitizer: markup.NewSanitizer(),
65
+
Files: Files,
64
66
}
65
67
66
68
p := &Pages{
···
71
73
rctx: rctx,
72
74
resolver: res,
73
75
templateDir: "appview/pages",
74
-
logger: slog.Default().With("component", "pages"),
76
+
logger: logger,
75
77
}
76
78
77
79
if p.dev {
···
220
222
221
223
type LoginParams struct {
222
224
ReturnUrl string
225
+
ErrorCode string
223
226
}
224
227
225
228
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
306
309
LoggedInUser *oauth.User
307
310
Timeline []models.TimelineEvent
308
311
Repos []models.Repo
312
+
GfiLabel *models.LabelDefinition
309
313
}
310
314
311
315
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
312
316
return p.execute("timeline/timeline", w, params)
313
317
}
314
318
319
+
type GoodFirstIssuesParams struct {
320
+
LoggedInUser *oauth.User
321
+
Issues []models.Issue
322
+
RepoGroups []*models.RepoGroup
323
+
LabelDefs map[string]*models.LabelDefinition
324
+
GfiLabel *models.LabelDefinition
325
+
Page pagination.Page
326
+
}
327
+
328
+
func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error {
329
+
return p.execute("goodfirstissues/index", w, params)
330
+
}
331
+
315
332
type UserProfileSettingsParams struct {
316
333
LoggedInUser *oauth.User
317
334
Tabs []map[string]any
···
326
343
LoggedInUser *oauth.User
327
344
Notifications []*models.NotificationWithEntity
328
345
UnreadCount int
329
-
HasMore bool
330
-
NextOffset int
331
-
Limit int
346
+
Page pagination.Page
347
+
Total int64
332
348
}
333
349
334
350
func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error {
···
344
360
}
345
361
346
362
type NotificationCountParams struct {
347
-
Count int
363
+
Count int64
348
364
}
349
365
350
366
func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error {
···
624
640
return p.executePlain("repo/fragments/repoStar", w, params)
625
641
}
626
642
627
-
type RepoDescriptionParams struct {
628
-
RepoInfo repoinfo.RepoInfo
629
-
}
630
-
631
-
func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
632
-
return p.executePlain("repo/fragments/editRepoDescription", w, params)
633
-
}
634
-
635
-
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
636
-
return p.executePlain("repo/fragments/repoDescription", w, params)
637
-
}
638
-
639
643
type RepoIndexParams struct {
640
644
LoggedInUser *oauth.User
641
645
RepoInfo repoinfo.RepoInfo
···
645
649
TagsTrunc []*types.TagReference
646
650
BranchesTrunc []types.Branch
647
651
// ForkInfo *types.ForkInfo
648
-
HTMLReadme template.HTML
649
-
Raw bool
650
-
EmailToDidOrHandle map[string]string
651
-
VerifiedCommits commitverify.VerifiedCommits
652
-
Languages []types.RepoLanguageDetails
653
-
Pipelines map[string]models.Pipeline
654
-
NeedsKnotUpgrade bool
652
+
HTMLReadme template.HTML
653
+
Raw bool
654
+
EmailToDid map[string]string
655
+
VerifiedCommits commitverify.VerifiedCommits
656
+
Languages []types.RepoLanguageDetails
657
+
Pipelines map[string]models.Pipeline
658
+
NeedsKnotUpgrade bool
655
659
types.RepoIndexResponse
656
660
}
657
661
···
686
690
}
687
691
688
692
type RepoLogParams struct {
689
-
LoggedInUser *oauth.User
690
-
RepoInfo repoinfo.RepoInfo
691
-
TagMap map[string][]string
693
+
LoggedInUser *oauth.User
694
+
RepoInfo repoinfo.RepoInfo
695
+
TagMap map[string][]string
696
+
Active string
697
+
EmailToDid map[string]string
698
+
VerifiedCommits commitverify.VerifiedCommits
699
+
Pipelines map[string]models.Pipeline
700
+
692
701
types.RepoLogResponse
693
-
Active string
694
-
EmailToDidOrHandle map[string]string
695
-
VerifiedCommits commitverify.VerifiedCommits
696
-
Pipelines map[string]models.Pipeline
697
702
}
698
703
699
704
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
···
702
707
}
703
708
704
709
type RepoCommitParams struct {
705
-
LoggedInUser *oauth.User
706
-
RepoInfo repoinfo.RepoInfo
707
-
Active string
708
-
EmailToDidOrHandle map[string]string
709
-
Pipeline *models.Pipeline
710
-
DiffOpts types.DiffOpts
710
+
LoggedInUser *oauth.User
711
+
RepoInfo repoinfo.RepoInfo
712
+
Active string
713
+
EmailToDid map[string]string
714
+
Pipeline *models.Pipeline
715
+
DiffOpts types.DiffOpts
711
716
712
717
// singular because it's always going to be just one
713
718
VerifiedCommit commitverify.VerifiedCommits
···
956
961
LabelDefs map[string]*models.LabelDefinition
957
962
Page pagination.Page
958
963
FilteringByOpen bool
964
+
FilterQuery string
959
965
}
960
966
961
967
func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
···
972
978
LabelDefs map[string]*models.LabelDefinition
973
979
974
980
OrderedReactionKinds []models.ReactionKind
975
-
Reactions map[models.ReactionKind]int
981
+
Reactions map[models.ReactionKind]models.ReactionDisplayData
976
982
UserReacted map[models.ReactionKind]bool
977
983
}
978
984
···
997
1003
ThreadAt syntax.ATURI
998
1004
Kind models.ReactionKind
999
1005
Count int
1006
+
Users []string
1000
1007
IsReacted bool
1001
1008
}
1002
1009
···
1085
1092
Pulls []*models.Pull
1086
1093
Active string
1087
1094
FilteringBy models.PullState
1095
+
FilterQuery string
1088
1096
Stacks map[string]models.Stack
1089
1097
Pipelines map[string]models.Pipeline
1098
+
LabelDefs map[string]*models.LabelDefinition
1090
1099
}
1091
1100
1092
1101
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1113
1122
}
1114
1123
1115
1124
type RepoSinglePullParams struct {
1116
-
LoggedInUser *oauth.User
1117
-
RepoInfo repoinfo.RepoInfo
1118
-
Active string
1119
-
Pull *models.Pull
1120
-
Stack models.Stack
1121
-
AbandonedPulls []*models.Pull
1122
-
MergeCheck types.MergeCheckResponse
1123
-
ResubmitCheck ResubmitResult
1124
-
Pipelines map[string]models.Pipeline
1125
+
LoggedInUser *oauth.User
1126
+
RepoInfo repoinfo.RepoInfo
1127
+
Active string
1128
+
Pull *models.Pull
1129
+
Stack models.Stack
1130
+
AbandonedPulls []*models.Pull
1131
+
BranchDeleteStatus *models.BranchDeleteStatus
1132
+
MergeCheck types.MergeCheckResponse
1133
+
ResubmitCheck ResubmitResult
1134
+
Pipelines map[string]models.Pipeline
1125
1135
1126
1136
OrderedReactionKinds []models.ReactionKind
1127
-
Reactions map[models.ReactionKind]int
1137
+
Reactions map[models.ReactionKind]models.ReactionDisplayData
1128
1138
UserReacted map[models.ReactionKind]bool
1139
+
1140
+
LabelDefs map[string]*models.LabelDefinition
1129
1141
}
1130
1142
1131
1143
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
1215
1227
}
1216
1228
1217
1229
type PullActionsParams struct {
1218
-
LoggedInUser *oauth.User
1219
-
RepoInfo repoinfo.RepoInfo
1220
-
Pull *models.Pull
1221
-
RoundNumber int
1222
-
MergeCheck types.MergeCheckResponse
1223
-
ResubmitCheck ResubmitResult
1224
-
Stack models.Stack
1230
+
LoggedInUser *oauth.User
1231
+
RepoInfo repoinfo.RepoInfo
1232
+
Pull *models.Pull
1233
+
RoundNumber int
1234
+
MergeCheck types.MergeCheckResponse
1235
+
ResubmitCheck ResubmitResult
1236
+
BranchDeleteStatus *models.BranchDeleteStatus
1237
+
Stack models.Stack
1225
1238
}
1226
1239
1227
1240
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
···
1337
1350
Name string
1338
1351
Command string
1339
1352
Collapsed bool
1353
+
StartTime time.Time
1340
1354
}
1341
1355
1342
1356
func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
1343
1357
return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
1358
+
}
1359
+
1360
+
type LogBlockEndParams struct {
1361
+
Id int
1362
+
StartTime time.Time
1363
+
EndTime time.Time
1364
+
}
1365
+
1366
+
func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error {
1367
+
return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params)
1344
1368
}
1345
1369
1346
1370
type LogLineParams struct {
···
1458
1482
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
1459
1483
}
1460
1484
1461
-
sub, err := fs.Sub(Files, "static")
1485
+
sub, err := fs.Sub(p.embedFS, "static")
1462
1486
if err != nil {
1463
1487
p.logger.Error("no static dir found? that's crazy", "err", err)
1464
1488
panic(err)
···
1481
1505
})
1482
1506
}
1483
1507
1484
-
func CssContentHash() string {
1485
-
cssFile, err := Files.Open("static/tw.css")
1508
+
func (p *Pages) CssContentHash() string {
1509
+
cssFile, err := p.embedFS.Open("static/tw.css")
1486
1510
if err != nil {
1487
1511
slog.Debug("Error opening CSS file", "err", err)
1488
1512
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"
5
4
"path"
6
5
"slices"
7
-
"strings"
8
6
9
7
"github.com/bluesky-social/indigo/atproto/syntax"
10
8
"tangled.org/core/appview/models"
11
9
"tangled.org/core/appview/state/userutil"
12
10
)
13
11
14
-
func (r RepoInfo) OwnerWithAt() string {
12
+
func (r RepoInfo) Owner() string {
15
13
if r.OwnerHandle != "" {
16
-
return fmt.Sprintf("@%s", r.OwnerHandle)
14
+
return r.OwnerHandle
17
15
} else {
18
16
return r.OwnerDid
19
17
}
20
18
}
21
19
22
20
func (r RepoInfo) FullName() string {
23
-
return path.Join(r.OwnerWithAt(), r.Name)
21
+
return path.Join(r.Owner(), r.Name)
24
22
}
25
23
26
24
func (r RepoInfo) OwnerWithoutAt() string {
27
-
if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok {
28
-
return after
25
+
if r.OwnerHandle != "" {
26
+
return r.OwnerHandle
29
27
} else {
30
28
return userutil.FlattenDid(r.OwnerDid)
31
29
}
···
56
54
OwnerDid string
57
55
OwnerHandle string
58
56
Description string
57
+
Website string
58
+
Topics []string
59
59
Knot string
60
60
Spindle string
61
61
RepoAt syntax.ATURI
+82
-54
appview/pages/templates/fragments/dolly/logo.html
+82
-54
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.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>
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>
56
84
{{ end }}
+60
-22
appview/pages/templates/fragments/dolly/silhouette.html
+60
-22
appview/pages/templates/fragments/dolly/silhouette.html
···
2
2
<svg
3
3
version="1.1"
4
4
id="svg1"
5
-
width="32"
6
-
height="32"
5
+
width="25"
6
+
height="25"
7
7
viewBox="0 0 25 25"
8
-
sodipodi:docname="tangled_dolly_silhouette.png"
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)"
9
13
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
10
14
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
11
15
xmlns="http://www.w3.org/2000/svg"
12
-
xmlns:svg="http://www.w3.org/2000/svg">
13
-
<style>
14
-
.dolly {
15
-
color: #000000;
16
-
}
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
+
}
17
23
18
-
@media (prefers-color-scheme: dark) {
19
-
.dolly {
20
-
color: #ffffff;
21
-
}
22
-
}
23
-
</style>
24
-
<title>Dolly</title>
25
-
<defs
26
-
id="defs1" />
24
+
@media (prefers-color-scheme: dark) {
25
+
.dolly {
26
+
color: #ffffff;
27
+
}
28
+
}
29
+
</style>
27
30
<sodipodi:namedview
28
31
id="namedview1"
29
32
pagecolor="#ffffff"
···
32
35
inkscape:showpageshadow="2"
33
36
inkscape:pageopacity="0.0"
34
37
inkscape:pagecheckerboard="true"
35
-
inkscape:deskcolor="#d1d1d1">
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">
36
49
<inkscape:page
37
50
x="0"
38
51
y="0"
···
45
58
<g
46
59
inkscape:groupmode="layer"
47
60
inkscape:label="Image"
48
-
id="g1">
61
+
id="g1"
62
+
transform="translate(-0.42924038,-0.87777209)">
49
63
<path
50
64
class="dolly"
51
65
fill="currentColor"
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" />
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" />
55
70
</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>
56
94
</svg>
57
95
{{ 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 }}
+17
-9
appview/pages/templates/knots/fragments/addMemberModal.html
+17
-9
appview/pages/templates/knots/fragments/addMemberModal.html
···
13
13
<div
14
14
id="add-member-{{ .Id }}"
15
15
popover
16
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
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">
17
19
{{ block "addKnotMemberPopover" . }} {{ end }}
18
20
</div>
19
21
{{ end }}
···
29
31
ADD MEMBER
30
32
</label>
31
33
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
32
-
<input
33
-
type="text"
34
-
id="member-did-{{ .Id }}"
35
-
name="member"
36
-
required
37
-
placeholder="@foo.bsky.social"
38
-
/>
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>
39
47
<div class="flex gap-2 pt-2">
40
48
<button
41
49
type="button"
···
54
62
</div>
55
63
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
56
64
</form>
57
-
{{ end }}
65
+
{{ 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="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="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">
6
6
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
7
7
8
8
{{ $lhs := printf "%s" $d.Name }}
+17
-12
appview/pages/templates/layouts/base.html
+17
-12
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>
12
13
13
14
<!-- preconnect to image cdn -->
14
15
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
16
<link rel="preconnect" href="https://camo.tangled.sh" />
17
+
18
+
<!-- pwa manifest -->
19
+
<link rel="manifest" href="/pwa-manifest.json" />
16
20
17
21
<!-- preload main font -->
18
22
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
···
21
25
<title>{{ block "title" . }}{{ end }} · tangled</title>
22
26
{{ block "extrameta" . }}{{ end }}
23
27
</head>
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);">
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">
26
29
{{ block "topbarLayout" . }}
27
-
<header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
30
+
<header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
28
31
29
32
{{ if .LoggedInUser }}
30
33
<div id="upgrade-banner"
···
38
41
{{ end }}
39
42
40
43
{{ block "mainLayout" . }}
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">
44
+
<div class="flex-grow">
45
+
<div class="max-w-screen-lg mx-auto flex flex-col gap-4">
46
+
{{ block "contentLayout" . }}
47
+
<main>
44
48
{{ block "content" . }}{{ end }}
45
49
</main>
46
-
{{ end }}
47
-
48
-
{{ block "contentAfterLayout" . }}
49
-
<main class="col-span-1 md:col-span-8">
50
+
{{ end }}
51
+
52
+
{{ block "contentAfterLayout" . }}
53
+
<main>
50
54
{{ block "contentAfter" . }}{{ end }}
51
55
</main>
52
-
{{ end }}
56
+
{{ end }}
57
+
</div>
53
58
</div>
54
59
{{ end }}
55
60
56
61
{{ block "footerLayout" . }}
57
-
<footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12">
62
+
<footer class="mt-12">
58
63
{{ template "layouts/fragments/footer" . }}
59
64
</footer>
60
65
{{ end }}
+7
-11
appview/pages/templates/layouts/fragments/topbar.html
+7
-11
appview/pages/templates/layouts/fragments/topbar.html
···
1
1
{{ define "layouts/fragments/topbar" }}
2
-
<nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
2
+
<nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800">
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 "dropDown" . }} {{ end }}
18
+
{{ block "profileDropdown" . }} {{ 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-4 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-3 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 "dropDown" }}
49
+
{{ define "profileDropdown" }}
50
50
<details class="relative inline-block text-left nav-dropdown">
51
-
<summary
52
-
class="cursor-pointer list-none flex items-center gap-1"
53
-
>
54
-
{{ $user := didOrHandle .Did .Handle }}
51
+
<summary class="cursor-pointer list-none flex items-center gap-1">
52
+
{{ $user := .Did }}
55
53
<img
56
54
src="{{ tinyAvatar $user }}"
57
55
alt=""
···
59
57
/>
60
58
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
61
59
</summary>
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
-
>
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">
65
61
<a href="/{{ $user }}">profile</a>
66
62
<a href="/{{ $user }}?tab=repos">repositories</a>
67
63
<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 }}
4
5
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
6
<meta property="og:type" content="profile" />
6
7
<meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" />
7
8
<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 }}" />
8
17
{{ end }}
9
18
10
19
{{ define "content" }}
+53
-25
appview/pages/templates/layouts/repobase.html
+53
-25
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 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>
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>
20
49
</div>
21
50
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>
51
+
<div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto">
29
52
{{ template "repo/fragments/repoStar" .RepoInfo }}
30
53
<a
31
54
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
···
36
59
fork
37
60
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
38
61
</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>
39
68
</div>
40
69
</div>
41
-
{{ template "repo/fragments/repoDescription" . }}
42
70
</section>
43
71
44
72
<section class="w-full flex flex-col" >
···
79
107
</div>
80
108
</nav>
81
109
{{ block "repoContentLayout" . }}
82
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
110
+
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white">
83
111
{{ block "repoContent" . }}{{ end }}
84
112
</section>
85
113
{{ block "repoAfter" . }}{{ end }}
+77
-199
appview/pages/templates/notifications/fragments/item.html
+77
-199
appview/pages/templates/notifications/fragments/item.html
···
1
1
{{define "notifications/fragments/item"}}
2
-
<div class="border border-gray-200 dark:border-gray-700 rounded-sm p-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors {{if not .Read}}bg-blue-50 dark:bg-blue-900/20{{end}}">
3
-
{{if .Issue}}
4
-
{{template "issueNotification" .}}
5
-
{{else if .Pull}}
6
-
{{template "pullNotification" .}}
7
-
{{else if .Repo}}
8
-
{{template "repoNotification" .}}
9
-
{{else if eq .Type "followed"}}
10
-
{{template "followNotification" .}}
11
-
{{else}}
12
-
{{template "genericNotification" .}}
13
-
{{end}}
14
-
</div>
15
-
{{end}}
16
-
17
-
{{define "issueNotification"}}
18
-
{{$url := printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
19
-
<a
20
-
href="{{$url}}"
21
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
22
-
>
23
-
<div class="flex items-center justify-between">
24
-
<div class="min-w-0 flex-1">
25
-
<!-- First line: icon + actor action -->
26
-
<div class="flex items-center gap-2 text-gray-900 dark:text-white">
27
-
{{if eq .Type "issue_created"}}
28
-
<span class="text-green-600 dark:text-green-500">
29
-
{{ i "circle-dot" "w-4 h-4" }}
30
-
</span>
31
-
{{else if eq .Type "issue_commented"}}
32
-
<span class="text-gray-500 dark:text-gray-400">
33
-
{{ i "message-circle" "w-4 h-4" }}
34
-
</span>
35
-
{{else if eq .Type "issue_closed"}}
36
-
<span class="text-gray-500 dark:text-gray-400">
37
-
{{ i "ban" "w-4 h-4" }}
38
-
</span>
39
-
{{end}}
40
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
41
-
{{if eq .Type "issue_created"}}
42
-
<span class="text-gray-500 dark:text-gray-400">opened issue</span>
43
-
{{else if eq .Type "issue_commented"}}
44
-
<span class="text-gray-500 dark:text-gray-400">commented on issue</span>
45
-
{{else if eq .Type "issue_closed"}}
46
-
<span class="text-gray-500 dark:text-gray-400">closed issue</span>
47
-
{{end}}
48
-
{{if not .Read}}
49
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
50
-
{{end}}
51
-
</div>
52
-
53
-
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1">
54
-
<span class="text-gray-500 dark:text-gray-400">#{{.Issue.IssueId}}</span>
55
-
<span class="text-gray-900 dark:text-white truncate">{{.Issue.Title}}</span>
56
-
<span>on</span>
57
-
<span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
58
-
</div>
59
-
</div>
60
-
61
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
62
-
{{ template "repo/fragments/time" .Created }}
63
-
</div>
64
-
</div>
65
-
</a>
66
-
{{end}}
67
-
68
-
{{define "pullNotification"}}
69
-
{{$url := printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
70
-
<a
71
-
href="{{$url}}"
72
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
73
-
>
74
-
<div class="flex items-center justify-between">
75
-
<div class="min-w-0 flex-1">
76
-
<div class="flex items-center gap-2 text-gray-900 dark:text-white">
77
-
{{if eq .Type "pull_created"}}
78
-
<span class="text-green-600 dark:text-green-500">
79
-
{{ i "git-pull-request-create" "w-4 h-4" }}
80
-
</span>
81
-
{{else if eq .Type "pull_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 "pull_merged"}}
86
-
<span class="text-purple-600 dark:text-purple-500">
87
-
{{ i "git-merge" "w-4 h-4" }}
88
-
</span>
89
-
{{else if eq .Type "pull_closed"}}
90
-
<span class="text-red-600 dark:text-red-500">
91
-
{{ i "git-pull-request-closed" "w-4 h-4" }}
92
-
</span>
93
-
{{end}}
94
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
95
-
{{if eq .Type "pull_created"}}
96
-
<span class="text-gray-500 dark:text-gray-400">opened pull request</span>
97
-
{{else if eq .Type "pull_commented"}}
98
-
<span class="text-gray-500 dark:text-gray-400">commented on pull request</span>
99
-
{{else if eq .Type "pull_merged"}}
100
-
<span class="text-gray-500 dark:text-gray-400">merged pull request</span>
101
-
{{else if eq .Type "pull_closed"}}
102
-
<span class="text-gray-500 dark:text-gray-400">closed pull request</span>
103
-
{{end}}
104
-
{{if not .Read}}
105
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
106
-
{{end}}
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>
107
16
</div>
108
17
109
-
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1">
110
-
<span class="text-gray-500 dark:text-gray-400">#{{.Pull.PullId}}</span>
111
-
<span class="text-gray-900 dark:text-white truncate">{{.Pull.Title}}</span>
112
-
<span>on</span>
113
-
<span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
114
-
</div>
115
18
</div>
116
-
117
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
118
-
{{ template "repo/fragments/time" .Created }}
119
-
</div>
120
-
</div>
121
-
</a>
19
+
</a>
122
20
{{end}}
123
21
124
-
{{define "repoNotification"}}
125
-
{{$url := printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
126
-
<a
127
-
href="{{$url}}"
128
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
129
-
>
130
-
<div class="flex items-center justify-between">
131
-
<div class="flex items-center gap-2 min-w-0 flex-1">
132
-
<span class="text-yellow-500 dark:text-yellow-400">
133
-
{{ i "star" "w-4 h-4" }}
134
-
</span>
135
-
136
-
<div class="min-w-0 flex-1">
137
-
<!-- Single line for stars: actor action subject -->
138
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
139
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
140
-
<span class="text-gray-500 dark:text-gray-400">starred</span>
141
-
<span class="font-medium">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
142
-
{{if not .Read}}
143
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
144
-
{{end}}
145
-
</div>
146
-
</div>
147
-
</div>
148
-
149
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
150
-
{{ template "repo/fragments/time" .Created }}
22
+
{{ define "notificationIcon" }}
23
+
<div class="flex-shrink-0 max-h-full w-16 h-16 relative">
24
+
<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">
26
+
{{ i .Icon "size-3 text-black dark:text-white" }}
151
27
</div>
152
28
</div>
153
-
</a>
154
-
{{end}}
29
+
{{ end }}
155
30
156
-
{{define "followNotification"}}
157
-
{{$url := printf "/%s" (resolve .ActorDid)}}
158
-
<a
159
-
href="{{$url}}"
160
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
161
-
>
162
-
<div class="flex items-center justify-between">
163
-
<div class="flex items-center gap-2 min-w-0 flex-1">
164
-
<span class="text-blue-600 dark:text-blue-400">
165
-
{{ i "user-plus" "w-4 h-4" }}
166
-
</span>
31
+
{{ define "notificationHeader" }}
32
+
{{ $actor := resolve .ActorDid }}
167
33
168
-
<div class="min-w-0 flex-1">
169
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
170
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
171
-
<span class="text-gray-500 dark:text-gray-400">followed you</span>
172
-
{{if not .Read}}
173
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
174
-
{{end}}
175
-
</div>
176
-
</div>
177
-
</div>
178
-
179
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
180
-
{{ template "repo/fragments/time" .Created }}
181
-
</div>
182
-
</div>
183
-
</a>
184
-
{{end}}
34
+
<span class="text-black dark:text-white w-fit">{{ $actor }}</span>
35
+
{{ if eq .Type "repo_starred" }}
36
+
starred <span class="text-black dark:text-white">{{ resolve .Repo.Did }}/{{ .Repo.Name }}</span>
37
+
{{ else if eq .Type "issue_created" }}
38
+
opened an issue
39
+
{{ else if eq .Type "issue_commented" }}
40
+
commented on an issue
41
+
{{ else if eq .Type "issue_closed" }}
42
+
closed an issue
43
+
{{ else if eq .Type "issue_reopen" }}
44
+
reopened an issue
45
+
{{ else if eq .Type "pull_created" }}
46
+
created a pull request
47
+
{{ else if eq .Type "pull_commented" }}
48
+
commented on a pull request
49
+
{{ else if eq .Type "pull_merged" }}
50
+
merged a pull request
51
+
{{ else if eq .Type "pull_closed" }}
52
+
closed a pull request
53
+
{{ else if eq .Type "pull_reopen" }}
54
+
reopened a pull request
55
+
{{ else if eq .Type "followed" }}
56
+
followed you
57
+
{{ else if eq .Type "user_mentioned" }}
58
+
mentioned you
59
+
{{ else }}
60
+
{{ end }}
61
+
{{ end }}
185
62
186
-
{{define "genericNotification"}}
187
-
<a
188
-
href="#"
189
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
190
-
>
191
-
<div class="flex items-center justify-between">
192
-
<div class="flex items-center gap-2 min-w-0 flex-1">
193
-
<span class="{{if not .Read}}text-blue-600 dark:text-blue-400{{else}}text-gray-500 dark:text-gray-400{{end}}">
194
-
{{ i "bell" "w-4 h-4" }}
195
-
</span>
63
+
{{ define "notificationSummary" }}
64
+
{{ if eq .Type "repo_starred" }}
65
+
<!-- no summary -->
66
+
{{ else if .Issue }}
67
+
#{{.Issue.IssueId}} {{.Issue.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}}
68
+
{{ else if .Pull }}
69
+
#{{.Pull.PullId}} {{.Pull.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}}
70
+
{{ else if eq .Type "followed" }}
71
+
<!-- no summary -->
72
+
{{ else }}
73
+
{{ end }}
74
+
{{ end }}
196
75
197
-
<div class="min-w-0 flex-1">
198
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
199
-
<span>New notification</span>
200
-
{{if not .Read}}
201
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
202
-
{{end}}
203
-
</div>
204
-
</div>
205
-
</div>
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 }}
206
88
207
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
208
-
{{ template "repo/fragments/time" .Created }}
209
-
</div>
210
-
</div>
211
-
</a>
212
-
{{end}}
89
+
{{ $url }}
90
+
{{ end }}
+44
-25
appview/pages/templates/notifications/list.html
+44
-25
appview/pages/templates/notifications/list.html
···
1
1
{{ define "title" }}notifications{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="p-6">
5
-
<div class="flex items-center justify-between mb-4">
4
+
<div class="px-6 py-4">
5
+
<div class="flex items-center justify-between">
6
6
<p class="text-xl font-bold dark:text-white">Notifications</p>
7
7
<a href="/settings/notifications" class="flex items-center gap-2">
8
8
{{ i "settings" "w-4 h-4" }}
···
11
11
</div>
12
12
</div>
13
13
14
-
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
15
-
{{if .Notifications}}
16
-
<div class="flex flex-col gap-4" id="notifications-list">
17
-
{{range .Notifications}}
18
-
{{template "notifications/fragments/item" .}}
19
-
{{end}}
20
-
</div>
14
+
{{if .Notifications}}
15
+
<div class="flex flex-col gap-2" id="notifications-list">
16
+
{{range .Notifications}}
17
+
{{template "notifications/fragments/item" .}}
18
+
{{end}}
19
+
</div>
21
20
22
-
{{if .HasMore}}
23
-
<div class="mt-6 text-center">
24
-
<button
25
-
class="btn gap-2 group"
26
-
hx-get="/notifications?offset={{.NextOffset}}&limit={{.Limit}}"
27
-
hx-target="#notifications-list"
28
-
hx-swap="beforeend"
29
-
>
30
-
{{ i "chevron-down" "w-4 h-4 group-[.htmx-request]:hidden" }}
31
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
32
-
Load more
33
-
</button>
34
-
</div>
35
-
{{end}}
36
-
{{else}}
21
+
{{else}}
22
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
37
23
<div class="text-center py-12">
38
24
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
39
25
{{ i "bell-off" "w-16 h-16" }}
···
41
27
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
42
28
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
43
29
</div>
44
-
{{end}}
30
+
</div>
31
+
{{end}}
32
+
33
+
{{ template "pagination" . }}
34
+
{{ end }}
35
+
36
+
{{ define "pagination" }}
37
+
<div class="flex justify-end mt-4 gap-2">
38
+
{{ if gt .Page.Offset 0 }}
39
+
{{ $prev := .Page.Previous }}
40
+
<a
41
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
42
+
hx-boost="true"
43
+
href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
44
+
>
45
+
{{ i "chevron-left" "w-4 h-4" }}
46
+
previous
47
+
</a>
48
+
{{ else }}
49
+
<div></div>
50
+
{{ end }}
51
+
52
+
{{ $next := .Page.Next }}
53
+
{{ if lt $next.Offset .Total }}
54
+
{{ $next := .Page.Next }}
55
+
<a
56
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
57
+
hx-boost="true"
58
+
href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
59
+
>
60
+
next
61
+
{{ i "chevron-right" "w-4 h-4" }}
62
+
</a>
63
+
{{ end }}
45
64
</div>
46
65
{{ 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 items-center space-x-2">
28
-
<p class="text-sm text-gray-500 dark:text-gray-300">
29
-
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
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 }}
30
30
31
-
{{ if $didOrHandle }}
32
-
<a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a>
31
+
{{ if $did }}
32
+
{{ template "user/fragments/picHandleLink" $did }}
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
+
36
37
<span class="px-1 select-none before:content-['\00B7']"></span>
37
38
{{ template "repo/fragments/time" $commit.Author.When }}
38
39
<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">
42
41
<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
-
{{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }}
62
-
<a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a>
61
+
{{ $committerDid := index $.EmailToDid $commit.Committer.Email }}
62
+
{{ template "user/fragments/picHandleLink" $committerDid }}
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="px-1 col-span-full" style="z-index: 20;">
83
+
<header class="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 col-span-full flex flex-col gap-4">
89
+
<div class="px-1 flex-grow 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="px-1 col-span-full mt-12">
108
+
<footer class="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 }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
38
+
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ .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
+
9
16
<fieldset class="space-y-3">
10
17
<legend class="dark:text-white">Select a knot to fork into</legend>
11
18
<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.sh" }}
4
+
{{ $knot = "tangled.org" }}
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/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
-
>https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
32
+
data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}"
33
+
>https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .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 }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
-
>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
51
+
data-url="git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
+
>git@{{ $knot | stripPort }}:{{ .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"
+20
-18
appview/pages/templates/repo/fragments/diffOpts.html
+20
-18
appview/pages/templates/repo/fragments/diffOpts.html
···
5
5
{{ if .Split }}
6
6
{{ $active = "split" }}
7
7
{{ end }}
8
-
{{ $values := list "unified" "split" }}
9
-
{{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ 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) }}
10
28
</section>
11
29
{{ end }}
12
30
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
+9
-1
appview/pages/templates/repo/fragments/og.html
+9
-1
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
-
5
+
{{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }}
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 }}" />
11
19
{{ 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 }}
+6
-1
appview/pages/templates/repo/fragments/reaction.html
+6
-1
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
5
+
leading-4 px-3 gap-1 relative group
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 }}
23
28
{{ if .IsReacted }}
24
29
hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
25
30
{{ 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 }}
+3
-13
appview/pages/templates/repo/index.html
+3
-13
appview/pages/templates/repo/index.html
···
222
222
class="mx-1 before:content-['·'] before:select-none"
223
223
></span>
224
224
<span>
225
-
{{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }}
226
-
<a
227
-
href="{{ if $didOrHandle }}
228
-
/{{ $didOrHandle }}
229
-
{{ else }}
230
-
mailto:{{ .Author.Email }}
231
-
{{ end }}"
225
+
{{ $did := index $.EmailToDid .Author.Email }}
226
+
<a href="{{ if $did }}/{{ resolve $did }}{{ else }}mailto:{{ .Author.Email }}{{ end }}"
232
227
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
233
-
>{{ if $didOrHandle }}
234
-
{{ template "user/fragments/picHandleLink" $didOrHandle }}
235
-
{{ else }}
236
-
{{ .Author.Name }}
237
-
{{ end }}</a
238
-
>
228
+
>{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ .Author.Name }}{{ end }}</a>
239
229
</span>
240
230
<div class="inline-block px-1 select-none after:content-['·']"></div>
241
231
{{ 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"
37
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
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"
47
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
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 }}
+7
-2
appview/pages/templates/repo/issues/fragments/newComment.html
+7
-2
appview/pages/templates/repo/issues/fragments/newComment.html
···
138
138
</div>
139
139
</form>
140
140
{{ else }}
141
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
142
-
<a href="/login" class="underline">login</a> to join the discussion
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
143
148
</div>
144
149
{{ end }}
145
150
{{ 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 }}
+9
-35
appview/pages/templates/repo/issues/issue.html
+9
-35
appview/pages/templates/repo/issues/issue.html
···
2
2
3
3
4
4
{{ define "extrameta" }}
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) }}
5
+
{{ template "repo/issues/fragments/og" (dict "RepoInfo" .RepoInfo "Issue" .Issue) }}
9
6
{{ end }}
10
7
11
8
{{ define "repoContentLayout" }}
···
22
19
"Defs" $.LabelDefs
23
20
"Subject" $.Issue.AtUri
24
21
"State" $.Issue.Labels) }}
25
-
{{ template "issueParticipants" . }}
22
+
{{ template "repo/fragments/participants" $.Issue.Participants }}
23
+
{{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }}
26
24
</div>
27
25
</div>
28
26
{{ end }}
···
87
85
88
86
{{ define "editIssue" }}
89
87
<a
90
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
88
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
91
89
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
92
90
hx-swap="innerHTML"
93
91
hx-target="#issue-{{.Issue.IssueId}}">
···
97
95
98
96
{{ define "deleteIssue" }}
99
97
<a
100
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
98
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
101
99
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/"
102
100
hx-confirm="Are you sure you want to delete your issue?"
103
101
hx-swap="none">
···
110
108
<div class="flex items-center gap-2">
111
109
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
112
110
{{ range $kind := .OrderedReactionKinds }}
111
+
{{ $reactionData := index $.Reactions $kind }}
113
112
{{
114
113
template "repo/fragments/reaction"
115
114
(dict
116
115
"Kind" $kind
117
-
"Count" (index $.Reactions $kind)
116
+
"Count" $reactionData.Count
118
117
"IsReacted" (index $.UserReacted $kind)
119
-
"ThreadAt" $.Issue.AtUri)
118
+
"ThreadAt" $.Issue.AtUri
119
+
"Users" $reactionData.Users)
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 }}
151
125
152
126
{{ define "repoAfter" }}
153
127
<div class="flex flex-col gap-4 mt-4">
+45
-76
appview/pages/templates/repo/issues/issues.html
+45
-76
appview/pages/templates/repo/issues/issues.html
···
8
8
{{ end }}
9
9
10
10
{{ define "repoContent" }}
11
-
<div class="flex justify-between items-center gap-4">
12
-
<div class="flex gap-4">
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>
13
47
<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>
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
29
48
href="/{{ .RepoInfo.FullName }}/issues/new"
30
-
class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
31
-
>
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
+
>
32
51
{{ i "circle-plus" "w-4 h-4" }}
33
52
<span>new</span>
34
-
</a>
35
-
</div>
36
-
<div class="error" id="issues"></div>
53
+
</a>
54
+
</div>
55
+
<div class="error" id="issues"></div>
37
56
{{ end }}
38
57
39
58
{{ define "repoAfter" }}
40
-
<div class="flex flex-col gap-2 mt-2">
41
-
{{ range .Issues }}
42
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
43
-
<div class="pb-2">
44
-
<a
45
-
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
46
-
class="no-underline hover:underline"
47
-
>
48
-
{{ .Title | description }}
49
-
<span class="text-gray-500">#{{ .IssueId }}</span>
50
-
</a>
51
-
</div>
52
-
<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 }}
59
+
<div class="mt-2">
60
+
{{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }}
92
61
</div>
93
62
{{ block "pagination" . }} {{ end }}
94
63
{{ end }}
···
105
74
<a
106
75
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
107
76
hx-boost="true"
108
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
77
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
109
78
>
110
79
{{ i "chevron-left" "w-4 h-4" }}
111
80
previous
···
119
88
<a
120
89
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
121
90
hx-boost="true"
122
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
91
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
123
92
>
124
93
next
125
94
{{ 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
-
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
31
-
{{ if $didOrHandle }}
32
-
{{ template "user/fragments/picHandleLink" $didOrHandle }}
30
+
{{ $did := index $.EmailToDid $commit.Author.Email }}
31
+
{{ if $did }}
32
+
{{ template "user/fragments/picHandleLink" $did }}
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
-
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
157
-
<a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
156
+
{{ $did := index $.EmailToDid $commit.Author.Email }}
157
+
<a href="{{ if $did }}/{{ $did }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
158
158
class="text-gray-500 dark:text-gray-400 no-underline hover:underline">
159
-
{{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }}
159
+
{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ $commit.Author.Name }}{{ end }}
160
160
</a>
161
161
</span>
162
162
<div class="inline-block px-1 select-none after:content-['·']"></div>
+7
-6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
+7
-6
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">
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>
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>
11
7
</summary>
12
8
<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>
13
9
</details>
14
10
</div>
15
11
{{ 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
+
+15
-3
appview/pages/templates/repo/pipelines/pipelines.html
+15
-3
appview/pages/templates/repo/pipelines/pipelines.html
···
12
12
{{ range .Pipelines }}
13
13
{{ block "pipeline" (list $ .) }} {{ end }}
14
14
{{ else }}
15
-
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
16
-
No pipelines run for this repository.
17
-
</p>
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>
18
30
{{ end }}
19
31
</div>
20
32
</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" }}
18
19
{{ end }}
19
20
20
21
{{ define "sidebar" }}
···
58
59
hx-ext="ws"
59
60
ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs">
60
61
<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>
61
67
</div>
62
68
</div>
63
69
{{ 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 }}
+81
-72
appview/pages/templates/repo/pulls/fragments/pullActions.html
+81
-72
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 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 }}
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 }}
51
61
52
-
{{ if and $isPullAuthor $isOpen $isLastRound }}
53
-
{{ $disabled := "" }}
54
-
{{ if $isUpToDate }}
55
-
{{ $disabled = "disabled" }}
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"
56
74
{{ 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 }}
65
75
66
-
hx-disabled-elt="#resubmitBtn"
67
-
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
76
+
hx-disabled-elt="#resubmitBtn"
77
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
68
78
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 }}
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 }}
80
90
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 }}
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 }}
91
101
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>
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 }}
103
112
</div>
104
113
{{ end }}
105
114
+15
-11
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+15
-11
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 .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 -}}
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 }}
54
56
</span>
55
57
{{ end }}
56
58
</span>
···
66
68
<div class="flex items-center gap-2 mt-2">
67
69
{{ template "repo/fragments/reactionsPopUp" . }}
68
70
{{ range $kind := . }}
71
+
{{ $reactionData := index $.Reactions $kind }}
69
72
{{
70
73
template "repo/fragments/reaction"
71
74
(dict
72
75
"Kind" $kind
73
-
"Count" (index $.Reactions $kind)
76
+
"Count" $reactionData.Count
74
77
"IsReacted" (index $.UserReacted $kind)
75
-
"ThreadAt" $.Pull.PullAt)
78
+
"ThreadAt" $.Pull.AtUri
79
+
"Users" $reactionData.Users)
76
80
}}
77
81
{{ end }}
78
82
</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
-
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
6
+
{{ resolve .LoggedInUser.Did }}
7
7
</div>
8
8
<form
9
9
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+1
-14
appview/pages/templates/repo/pulls/interdiff.html
+1
-14
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
-
37
31
{{ define "mainLayout" }}
38
-
<div class="px-1 col-span-full flex flex-col gap-4">
32
+
<div class="px-1 col-span-full flex-grow flex flex-col gap-4">
39
33
{{ block "contentLayout" . }}
40
34
{{ block "content" . }}{{ end }}
41
35
{{ end }}
···
52
46
{{ end }}
53
47
</div>
54
48
{{ 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
-
62
49
63
50
{{ define "contentAfter" }}
64
51
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+1
-13
appview/pages/templates/repo/pulls/patch.html
+1
-13
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
-
43
37
{{ define "mainLayout" }}
44
-
<div class="px-1 col-span-full flex flex-col gap-4">
38
+
<div class="px-1 col-span-full flex-grow flex flex-col gap-4">
45
39
{{ block "contentLayout" . }}
46
40
{{ block "content" . }}{{ end }}
47
41
{{ end }}
···
57
51
</div>
58
52
{{ end }}
59
53
</div>
60
-
{{ end }}
61
-
62
-
{{ define "footerLayout" }}
63
-
<footer class="px-1 col-span-full mt-12">
64
-
{{ template "layouts/fragments/footer" . }}
65
-
</footer>
66
54
{{ end }}
67
55
68
56
{{ define "contentAfter" }}
+49
-20
appview/pages/templates/repo/pulls/pull.html
+49
-20
appview/pages/templates/repo/pulls/pull.html
···
3
3
{{ end }}
4
4
5
5
{{ define "extrameta" }}
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 }}
6
+
{{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }}
7
+
{{ end }}
8
8
9
-
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
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>
10
27
{{ end }}
11
-
12
28
13
29
{{ define "repoContent" }}
14
30
{{ template "repo/pulls/fragments/pullHeader" . }}
···
39
55
{{ with $item }}
40
56
<details {{ if eq $idx $lastIdx }}open{{ end }}>
41
57
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
42
-
<div class="flex flex-wrap gap-2 items-center">
58
+
<div class="flex flex-wrap gap-2 items-stretch">
43
59
<!-- round number -->
44
60
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
45
61
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
46
62
</div>
47
63
<!-- round summary -->
48
-
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
64
+
<div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
49
65
<span class="gap-1 flex items-center">
50
66
{{ $owner := resolve $.Pull.OwnerDid }}
51
67
{{ $re := "re" }}
···
72
88
<span class="hidden md:inline">diff</span>
73
89
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
74
90
</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>
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>
99
+
{{ end }}
83
100
<span id="interdiff-error-{{.RoundNumber}}"></span>
84
-
{{ end }}
85
101
</div>
86
102
</summary>
87
103
···
146
162
147
163
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
148
164
{{ range $cidx, $c := .Comments }}
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">
165
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full">
150
166
{{ if gt $cidx 0 }}
151
167
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
152
168
{{ end }}
···
169
185
{{ end }}
170
186
171
187
{{ if $.LoggedInUser }}
172
-
{{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }}
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) }}
173
198
{{ else }}
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
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
177
206
</div>
178
207
{{ end }}
179
208
</div>
+59
-34
appview/pages/templates/repo/pulls/pulls.html
+59
-34
appview/pages/templates/repo/pulls/pulls.html
···
8
8
{{ end }}
9
9
10
10
{{ define "repoContent" }}
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>
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) }}
42
52
</div>
43
-
<div class="error" id="pulls"></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>
44
62
{{ end }}
45
63
46
64
{{ define "repoAfter" }}
···
108
126
<span class="before:content-['·']"></span>
109
127
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
110
128
{{ 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 }}
111
136
</div>
112
137
</div>
113
138
{{ if .StackId }}
···
126
151
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
127
152
</div>
128
153
</summary>
129
-
{{ block "pullList" (list $otherPulls $) }} {{ end }}
154
+
{{ block "stackedPullList" (list $otherPulls $) }} {{ end }}
130
155
</details>
131
156
{{ end }}
132
157
{{ end }}
···
135
160
</div>
136
161
{{ end }}
137
162
138
-
{{ define "pullList" }}
163
+
{{ define "stackedPullList" }}
139
164
{{ $list := index . 0 }}
140
165
{{ $root := index . 1 }}
141
166
<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">
+17
-8
appview/pages/templates/repo/settings/access.html
+17
-8
appview/pages/templates/repo/settings/access.html
···
66
66
<div
67
67
id="add-collaborator-modal"
68
68
popover
69
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
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">
70
73
{{ template "addCollaboratorModal" . }}
71
74
</div>
72
75
{{ end }}
···
82
85
ADD COLLABORATOR
83
86
</label>
84
87
<p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p>
85
-
<input
86
-
type="text"
87
-
id="add-collaborator"
88
-
name="collaborator"
89
-
required
90
-
placeholder="@foo.bsky.social"
91
-
/>
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>
92
101
<div class="flex gap-2 pt-2">
93
102
<button
94
103
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" . }}
9
10
{{ template "branchSettings" . }}
10
11
{{ template "defaultLabelSettings" . }}
11
12
{{ template "customLabelSettings" . }}
···
13
14
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
14
15
</div>
15
16
</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>
16
63
{{ end }}
17
64
18
65
{{ define "branchSettings" }}
+16
-8
appview/pages/templates/spindles/fragments/addMemberModal.html
+16
-8
appview/pages/templates/spindles/fragments/addMemberModal.html
···
13
13
<div
14
14
id="add-member-{{ .Instance }}"
15
15
popover
16
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
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">
17
19
{{ block "addSpindleMemberPopover" . }} {{ end }}
18
20
</div>
19
21
{{ end }}
···
29
31
ADD MEMBER
30
32
</label>
31
33
<p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p>
32
-
<input
33
-
type="text"
34
-
id="member-did-{{ .Id }}"
35
-
name="member"
36
-
required
37
-
placeholder="@foo.bsky.social"
38
-
/>
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>
39
47
<div class="flex gap-2 pt-2">
40
48
<button
41
49
type="button"
+1
-1
appview/pages/templates/strings/string.html
+1
-1
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 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">
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">
51
51
<span>
52
52
{{ .String.Filename }}
53
53
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+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 new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
7
+
Tangled is a decentralized Git hosting and collaboration platform.
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" . }}
15
16
{{ template "timeline/fragments/trending" . }}
16
17
{{ template "timeline/fragments/timeline" . }}
17
18
<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">
23
34
<label class="m-0 p-0" for="location">location</label>
24
35
<div class="flex items-center gap-2 w-full">
25
36
{{ $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 }}" />
6
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $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">
+19
-6
appview/pages/templates/user/fragments/profileCard.html
+19
-6
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
-
<a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a>
15
+
{{ with .Profile }}
16
+
{{ if .Pronouns }}
17
+
<p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p>
18
+
{{ end }}
19
+
{{ end }}
16
20
</div>
17
21
18
22
<div class="md:hidden">
···
67
71
{{ end }}
68
72
</div>
69
73
{{ end }}
70
-
{{ if ne .FollowStatus.String "IsSelf" }}
71
-
{{ template "user/fragments/follow" . }}
72
-
{{ else }}
74
+
75
+
<div class="flex mt-2 items-center gap-2">
76
+
{{ if ne .FollowStatus.String "IsSelf" }}
77
+
{{ template "user/fragments/follow" . }}
78
+
{{ else }}
73
79
<button id="editBtn"
74
-
class="btn mt-2 w-full flex items-center gap-2 group"
80
+
class="btn w-full flex items-center gap-2 group"
75
81
hx-target="#profile-bio"
76
82
hx-get="/profile/edit-bio"
77
83
hx-swap="innerHTML">
···
79
85
edit
80
86
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
81
87
</button>
82
-
{{ end }}
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
+
83
96
</div>
84
97
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
85
98
</div>
+24
-2
appview/pages/templates/user/login.html
+24
-2
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" />
11
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
13
<title>login · tangled</title>
13
14
</head>
14
15
<body class="flex items-center justify-center min-h-screen">
15
-
<main class="max-w-md px-6 -mt-4">
16
+
<main class="max-w-md px-7 mt-4">
16
17
<h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" >
17
18
{{ template "fragments/logotype" }}
18
19
</h1>
···
20
21
tightly-knit social coding.
21
22
</h2>
22
23
<form
23
-
class="mt-4 max-w-sm mx-auto"
24
+
class="mt-4"
24
25
hx-post="/login"
25
26
hx-swap="none"
26
27
hx-disabled-elt="#login-button"
···
28
29
<div class="flex flex-col">
29
30
<label for="handle">handle</label>
30
31
<input
32
+
autocapitalize="none"
33
+
autocorrect="off"
34
+
autocomplete="username"
31
35
type="text"
32
36
id="handle"
33
37
name="handle"
···
52
56
<span>login</span>
53
57
</button>
54
58
</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 }}
55
77
<p class="text-sm text-gray-500">
56
78
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
57
79
</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">
147
161
<span class="font-bold">Email notifications</span>
148
162
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
149
163
<span>Receive notifications via email in addition to in-app notifications.</span>
+1
-3
appview/pages/templates/user/settings/profile.html
+1
-3
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 }}
37
36
<span class="font-bold">
38
-
@{{ .LoggedInUser.Handle }}
37
+
{{ resolve .LoggedInUser.Did }}
39
38
</span>
40
-
{{ end }}
41
39
</div>
42
40
</div>
43
41
<div class="flex items-center justify-between p-4">
+3
-2
appview/pages/templates/user/signup.html
+3
-2
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" />
11
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
13
<title>sign up · tangled</title>
13
14
···
41
42
invite code, desired username, and password in the next
42
43
page to complete your registration.
43
44
</span>
44
-
<div class="w-full mt-4">
45
-
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
45
+
<div class="w-full mt-4 text-center">
46
+
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
46
47
</div>
47
48
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
48
49
<span>join now</span>
+47
-1
appview/pagination/page.go
+47
-1
appview/pagination/page.go
···
1
1
package pagination
2
2
3
+
import "context"
4
+
3
5
type Page struct {
4
6
Offset int // where to start from
5
7
Limit int // number of items in a page
···
8
10
func FirstPage() Page {
9
11
return Page{
10
12
Offset: 0,
11
-
Limit: 10,
13
+
Limit: 30,
12
14
}
13
15
}
14
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
+
}
37
+
15
38
func (p Page) Previous() Page {
16
39
if p.Offset-p.Limit < 0 {
17
40
return FirstPage()
···
29
52
Limit: p.Limit,
30
53
}
31
54
}
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
+
}
+37
-17
appview/pipelines/pipelines.go
+37
-17
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"
20
19
"tangled.org/core/rbac"
21
20
spindlemodel "tangled.org/core/spindle/models"
22
21
···
36
35
logger *slog.Logger
37
36
}
38
37
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
+
39
47
func New(
40
48
oauth *oauth.OAuth,
41
49
repoResolver *reporesolver.RepoResolver,
···
45
53
db *db.DB,
46
54
config *config.Config,
47
55
enforcer *rbac.Enforcer,
56
+
logger *slog.Logger,
48
57
) *Pipelines {
49
-
logger := log.New("pipelines")
50
-
51
-
return &Pipelines{oauth: oauth,
58
+
return &Pipelines{
59
+
oauth: oauth,
52
60
repoResolver: repoResolver,
53
61
pages: pages,
54
62
idResolver: idResolver,
···
228
236
// start a goroutine to read from spindle
229
237
go readLogs(spindleConn, evChan)
230
238
231
-
stepIdx := 0
239
+
stepStartTimes := make(map[int]time.Time)
232
240
var fragment bytes.Buffer
233
241
for {
234
242
select {
···
260
268
261
269
switch logLine.Kind {
262
270
case spindlemodel.LogKindControl:
263
-
// control messages create a new step block
264
-
stepIdx++
265
-
collapsed := false
266
-
if logLine.StepKind == spindlemodel.StepKindSystem {
267
-
collapsed = true
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
+
})
268
293
}
269
-
err = p.pages.LogBlock(&fragment, pages.LogBlockParams{
270
-
Id: stepIdx,
271
-
Name: logLine.Content,
272
-
Command: logLine.StepCommand,
273
-
Collapsed: collapsed,
274
-
})
294
+
275
295
case spindlemodel.LogKindData:
276
296
// data messages simply insert new log lines into current step
277
297
err = p.pages.LogLine(&fragment, pages.LogLineParams{
278
-
Id: stepIdx,
298
+
Id: logLine.StepId,
279
299
Content: logLine.Content,
280
300
})
281
301
}
-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
+
}
+282
-196
appview/pulls/pulls.go
+282
-196
appview/pulls/pulls.go
···
6
6
"errors"
7
7
"fmt"
8
8
"log"
9
+
"log/slog"
9
10
"net/http"
11
+
"slices"
10
12
"sort"
11
13
"strconv"
12
14
"strings"
···
15
17
"tangled.org/core/api/tangled"
16
18
"tangled.org/core/appview/config"
17
19
"tangled.org/core/appview/db"
20
+
pulls_indexer "tangled.org/core/appview/indexer/pulls"
18
21
"tangled.org/core/appview/models"
19
22
"tangled.org/core/appview/notify"
20
23
"tangled.org/core/appview/oauth"
21
24
"tangled.org/core/appview/pages"
22
25
"tangled.org/core/appview/pages/markup"
23
26
"tangled.org/core/appview/reporesolver"
27
+
"tangled.org/core/appview/validator"
24
28
"tangled.org/core/appview/xrpcclient"
25
29
"tangled.org/core/idresolver"
26
30
"tangled.org/core/patchutil"
31
+
"tangled.org/core/rbac"
27
32
"tangled.org/core/tid"
28
33
"tangled.org/core/types"
29
34
30
-
"github.com/bluekeyes/go-gitdiff/gitdiff"
31
35
comatproto "github.com/bluesky-social/indigo/api/atproto"
36
+
"github.com/bluesky-social/indigo/atproto/syntax"
32
37
lexutil "github.com/bluesky-social/indigo/lex/util"
33
38
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
34
39
"github.com/go-chi/chi/v5"
···
43
48
db *db.DB
44
49
config *config.Config
45
50
notifier notify.Notifier
51
+
enforcer *rbac.Enforcer
52
+
logger *slog.Logger
53
+
validator *validator.Validator
54
+
indexer *pulls_indexer.Indexer
46
55
}
47
56
48
57
func New(
···
53
62
db *db.DB,
54
63
config *config.Config,
55
64
notifier notify.Notifier,
65
+
enforcer *rbac.Enforcer,
66
+
validator *validator.Validator,
67
+
indexer *pulls_indexer.Indexer,
68
+
logger *slog.Logger,
56
69
) *Pulls {
57
70
return &Pulls{
58
71
oauth: oauth,
···
62
75
db: db,
63
76
config: config,
64
77
notifier: notifier,
78
+
enforcer: enforcer,
79
+
logger: logger,
80
+
validator: validator,
81
+
indexer: indexer,
65
82
}
66
83
}
67
84
···
98
115
}
99
116
100
117
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
118
+
branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
101
119
resubmitResult := pages.Unknown
102
120
if user.Did == pull.OwnerDid {
103
121
resubmitResult = s.resubmitCheck(r, f, pull, stack)
104
122
}
105
123
106
124
s.pages.PullActionsFragment(w, pages.PullActionsParams{
107
-
LoggedInUser: user,
108
-
RepoInfo: f.RepoInfo(user),
109
-
Pull: pull,
110
-
RoundNumber: roundNumber,
111
-
MergeCheck: mergeCheckResponse,
112
-
ResubmitCheck: resubmitResult,
113
-
Stack: stack,
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,
114
133
})
115
134
return
116
135
}
···
135
154
stack, _ := r.Context().Value("stack").(models.Stack)
136
155
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
137
156
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
-
155
157
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
158
+
branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
156
159
resubmitResult := pages.Unknown
157
160
if user != nil && user.Did == pull.OwnerDid {
158
161
resubmitResult = s.resubmitCheck(r, f, pull, stack)
···
189
192
m[p.Sha] = p
190
193
}
191
194
192
-
reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt())
195
+
reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri())
193
196
if err != nil {
194
197
log.Println("failed to get pull reactions")
195
198
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
···
197
200
198
201
userReactions := map[models.ReactionKind]bool{}
199
202
if user != nil {
200
-
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
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
201
220
}
202
221
203
222
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
204
-
LoggedInUser: user,
205
-
RepoInfo: repoInfo,
206
-
Pull: pull,
207
-
Stack: stack,
208
-
AbandonedPulls: abandonedPulls,
209
-
MergeCheck: mergeCheckResponse,
210
-
ResubmitCheck: resubmitResult,
211
-
Pipelines: m,
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,
212
232
213
233
OrderedReactionKinds: models.OrderedReactionKinds,
214
-
Reactions: reactionCountMap,
234
+
Reactions: reactionMap,
215
235
UserReacted: userReactions,
236
+
237
+
LabelDefs: defs,
216
238
})
217
239
}
218
240
···
283
305
return result
284
306
}
285
307
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
+
286
363
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
287
364
if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
288
365
return pages.Unknown
···
330
407
331
408
targetBranch := branchResp
332
409
333
-
latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev
410
+
latestSourceRev := pull.LatestSha()
334
411
335
412
if pull.IsStacked() && stack != nil {
336
413
top := stack[0]
337
-
latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev
414
+
latestSourceRev = top.LatestSha()
338
415
}
339
416
340
417
if latestSourceRev != targetBranch.Hash {
···
374
451
return
375
452
}
376
453
377
-
patch := pull.Submissions[roundIdInt].Patch
454
+
patch := pull.Submissions[roundIdInt].CombinedPatch()
378
455
diff := patchutil.AsNiceDiff(patch, pull.TargetBranch)
379
456
380
457
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
···
425
502
return
426
503
}
427
504
428
-
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch)
505
+
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch())
429
506
if err != nil {
430
507
log.Println("failed to interdiff; current patch malformed")
431
508
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
432
509
return
433
510
}
434
511
435
-
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch)
512
+
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch())
436
513
if err != nil {
437
514
log.Println("failed to interdiff; previous patch malformed")
438
515
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
···
472
549
}
473
550
474
551
func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) {
552
+
l := s.logger.With("handler", "RepoPulls")
553
+
475
554
user := s.oauth.GetUser(r)
476
555
params := r.URL.Query()
477
556
···
489
568
return
490
569
}
491
570
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
+
492
598
pulls, err := db.GetPulls(
493
599
s.db,
494
-
db.FilterEq("repo_at", f.RepoAt()),
495
-
db.FilterEq("state", state),
600
+
db.FilterIn("id", ids),
496
601
)
497
602
if err != nil {
498
603
log.Println("failed to get pulls", err)
···
557
662
m[p.Sha] = p
558
663
}
559
664
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
+
560
681
s.pages.RepoPulls(w, pages.RepoPullsParams{
561
682
LoggedInUser: s.oauth.GetUser(r),
562
683
RepoInfo: f.RepoInfo(user),
563
684
Pulls: pulls,
685
+
LabelDefs: defs,
564
686
FilteringBy: state,
687
+
FilterQuery: keyword,
565
688
Stacks: stacks,
566
689
Pipelines: m,
567
690
})
568
691
}
569
692
570
693
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
694
+
l := s.logger.With("handler", "PullComment")
571
695
user := s.oauth.GetUser(r)
572
696
f, err := s.repoResolver.Resolve(r)
573
697
if err != nil {
···
617
741
618
742
createdAt := time.Now().Format(time.RFC3339)
619
743
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
-
627
744
client, err := s.oauth.AuthorizedClient(r)
628
745
if err != nil {
629
746
log.Println("failed to get authorized client", err)
630
747
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
631
748
return
632
749
}
633
-
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
750
+
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
634
751
Collection: tangled.RepoPullCommentNSID,
635
752
Repo: user.Did,
636
753
Rkey: tid.TID(),
637
754
Record: &lexutil.LexiconTypeDecoder{
638
755
Val: &tangled.RepoPullComment{
639
-
Pull: string(pullAt),
756
+
Pull: pull.AtUri().String(),
640
757
Body: body,
641
758
CreatedAt: createdAt,
642
759
},
···
672
789
return
673
790
}
674
791
675
-
s.notifier.NewPullComment(r.Context(), comment)
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)
676
802
677
803
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
678
804
return
···
884
1010
}
885
1011
886
1012
sourceRev := comparison.Rev2
887
-
patch := comparison.Patch
1013
+
patch := comparison.FormatPatchRaw
1014
+
combined := comparison.CombinedPatchRaw
888
1015
889
-
if !patchutil.IsPatchValid(patch) {
1016
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1017
+
s.logger.Error("failed to validate patch", "err", err)
890
1018
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
891
1019
return
892
1020
}
···
899
1027
Sha: comparison.Rev2,
900
1028
}
901
1029
902
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
1030
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
903
1031
}
904
1032
905
1033
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
906
-
if !patchutil.IsPatchValid(patch) {
1034
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1035
+
s.logger.Error("patch validation failed", "err", err)
907
1036
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
908
1037
return
909
1038
}
910
1039
911
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked)
1040
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked)
912
1041
}
913
1042
914
1043
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) {
···
991
1120
}
992
1121
993
1122
sourceRev := comparison.Rev2
994
-
patch := comparison.Patch
1123
+
patch := comparison.FormatPatchRaw
1124
+
combined := comparison.CombinedPatchRaw
995
1125
996
-
if !patchutil.IsPatchValid(patch) {
1126
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1127
+
s.logger.Error("failed to validate patch", "err", err)
997
1128
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
998
1129
return
999
1130
}
···
1011
1142
Sha: sourceRev,
1012
1143
}
1013
1144
1014
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
1145
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1015
1146
}
1016
1147
1017
1148
func (s *Pulls) createPullRequest(
···
1021
1152
user *oauth.User,
1022
1153
title, body, targetBranch string,
1023
1154
patch string,
1155
+
combined string,
1024
1156
sourceRev string,
1025
1157
pullSource *models.PullSource,
1026
1158
recordPullSource *tangled.RepoPull_Source,
···
1058
1190
1059
1191
// We've already checked earlier if it's diff-based and title is empty,
1060
1192
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1061
-
if title == "" {
1193
+
if title == "" || body == "" {
1062
1194
formatPatches, err := patchutil.ExtractPatches(patch)
1063
1195
if err != nil {
1064
1196
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
···
1069
1201
return
1070
1202
}
1071
1203
1072
-
title = formatPatches[0].Title
1073
-
body = formatPatches[0].Body
1204
+
if title == "" {
1205
+
title = formatPatches[0].Title
1206
+
}
1207
+
if body == "" {
1208
+
body = formatPatches[0].Body
1209
+
}
1074
1210
}
1075
1211
1076
1212
rkey := tid.TID()
1077
1213
initialSubmission := models.PullSubmission{
1078
1214
Patch: patch,
1215
+
Combined: combined,
1079
1216
SourceRev: sourceRev,
1080
1217
}
1081
1218
pull := &models.Pull{
···
1103
1240
return
1104
1241
}
1105
1242
1106
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1243
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1107
1244
Collection: tangled.RepoPullNSID,
1108
1245
Repo: user.Did,
1109
1246
Rkey: rkey,
···
1114
1251
Repo: string(f.RepoAt()),
1115
1252
Branch: targetBranch,
1116
1253
},
1117
-
Patch: patch,
1118
-
Source: recordPullSource,
1254
+
Patch: patch,
1255
+
Source: recordPullSource,
1256
+
CreatedAt: time.Now().Format(time.RFC3339),
1119
1257
},
1120
1258
},
1121
1259
})
···
1200
1338
}
1201
1339
writes = append(writes, &write)
1202
1340
}
1203
-
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1341
+
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1204
1342
Repo: user.Did,
1205
1343
Writes: writes,
1206
1344
})
···
1250
1388
return
1251
1389
}
1252
1390
1253
-
if patch == "" || !patchutil.IsPatchValid(patch) {
1391
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1392
+
s.logger.Error("faield to validate patch", "err", err)
1254
1393
s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1255
1394
return
1256
1395
}
···
1504
1643
1505
1644
patch := r.FormValue("patch")
1506
1645
1507
-
s.resubmitPullHelper(w, r, f, user, pull, patch, "")
1646
+
s.resubmitPullHelper(w, r, f, user, pull, patch, "", "")
1508
1647
}
1509
1648
1510
1649
func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
···
1565
1704
}
1566
1705
1567
1706
sourceRev := comparison.Rev2
1568
-
patch := comparison.Patch
1707
+
patch := comparison.FormatPatchRaw
1708
+
combined := comparison.CombinedPatchRaw
1569
1709
1570
-
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1710
+
s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1571
1711
}
1572
1712
1573
1713
func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
···
1599
1739
return
1600
1740
}
1601
1741
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
-
1628
1742
// update the hidden tracking branch to latest
1629
1743
client, err := s.oauth.ServiceClient(
1630
1744
r,
···
1656
1770
return
1657
1771
}
1658
1772
1659
-
// Use the fork comparison we already made
1660
-
comparison := forkComparison
1661
-
1662
-
sourceRev := comparison.Rev2
1663
-
patch := comparison.Patch
1664
-
1665
-
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1666
-
}
1667
-
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.")
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"
1672
1778
}
1673
-
1674
-
if patch == pull.LatestPatch() {
1675
-
return fmt.Errorf("Patch is identical to previous submission.")
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
1676
1791
}
1677
1792
1678
-
if !patchutil.IsPatchValid(patch) {
1679
-
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
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
1680
1798
}
1681
1799
1682
-
return nil
1800
+
// Use the fork comparison we already made
1801
+
comparison := forkComparison
1802
+
1803
+
sourceRev := comparison.Rev2
1804
+
patch := comparison.FormatPatchRaw
1805
+
combined := comparison.CombinedPatchRaw
1806
+
1807
+
s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1683
1808
}
1684
1809
1685
1810
func (s *Pulls) resubmitPullHelper(
···
1689
1814
user *oauth.User,
1690
1815
pull *models.Pull,
1691
1816
patch string,
1817
+
combined string,
1692
1818
sourceRev string,
1693
1819
) {
1694
1820
if pull.IsStacked() {
···
1697
1823
return
1698
1824
}
1699
1825
1700
-
if err := validateResubmittedPatch(pull, patch); err != nil {
1826
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1701
1827
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.")
1702
1833
return
1703
1834
}
1704
1835
1705
1836
// validate sourceRev if branch/fork based
1706
1837
if pull.IsBranchBased() || pull.IsForkBased() {
1707
-
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1838
+
if sourceRev == pull.LatestSha() {
1708
1839
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1709
1840
return
1710
1841
}
···
1718
1849
}
1719
1850
defer tx.Rollback()
1720
1851
1721
-
err = db.ResubmitPull(tx, pull, patch, sourceRev)
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)
1722
1858
if err != nil {
1723
1859
log.Println("failed to create pull request", err)
1724
1860
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
···
1731
1867
return
1732
1868
}
1733
1869
1734
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1870
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1735
1871
if err != nil {
1736
1872
// failed to get record
1737
1873
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
···
1754
1890
}
1755
1891
}
1756
1892
1757
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1893
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1758
1894
Collection: tangled.RepoPullNSID,
1759
1895
Repo: user.Did,
1760
1896
Rkey: pull.Rkey,
···
1766
1902
Repo: string(f.RepoAt()),
1767
1903
Branch: pull.TargetBranch,
1768
1904
},
1769
-
Patch: patch, // new patch
1770
-
Source: recordPullSource,
1905
+
Patch: patch, // new patch
1906
+
Source: recordPullSource,
1907
+
CreatedAt: time.Now().Format(time.RFC3339),
1771
1908
},
1772
1909
},
1773
1910
})
···
1818
1955
// commits that got deleted: corresponding pull is closed
1819
1956
// commits that got added: new pull is created
1820
1957
// 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
1823
1958
additions := make(map[string]*models.Pull)
1824
1959
deletions := make(map[string]*models.Pull)
1825
-
unchanged := make(map[string]struct{})
1826
1960
updated := make(map[string]struct{})
1827
1961
1828
1962
// pulls in orignal stack but not in new one
···
1844
1978
for _, np := range newStack {
1845
1979
if op, ok := origById[np.ChangeId]; ok {
1846
1980
// pull exists in both stacks
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
-
}
1981
+
updated[op.ChangeId] = struct{}{}
1867
1982
}
1868
1983
}
1869
1984
···
1930
2045
continue
1931
2046
}
1932
2047
1933
-
submission := np.Submissions[np.LastRoundNumber()]
1934
-
1935
-
// resubmit the old pull
1936
-
err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev)
1937
-
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)
1938
2055
if err != nil {
1939
2056
log.Println("failed to update pull", err, op.PullId)
1940
2057
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1941
2058
return
1942
2059
}
1943
2060
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
-
1977
-
if err != nil {
1978
-
log.Println("failed to update pull", err, op.PullId)
1979
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1980
-
return
1981
-
}
1982
-
1983
-
record := op.AsRecord()
1984
-
record.Patch = newSubmission.Patch
2061
+
record := np.AsRecord()
1985
2062
1986
2063
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1987
2064
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
···
2026
2103
return
2027
2104
}
2028
2105
2029
-
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
2106
+
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
2030
2107
Repo: user.Did,
2031
2108
Writes: writes,
2032
2109
})
···
2040
2117
}
2041
2118
2042
2119
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
2120
+
user := s.oauth.GetUser(r)
2043
2121
f, err := s.repoResolver.Resolve(r)
2044
2122
if err != nil {
2045
2123
log.Println("failed to resolve repo:", err)
···
2137
2215
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2138
2216
return
2139
2217
}
2218
+
p.State = models.PullMerged
2140
2219
}
2141
2220
2142
2221
err = tx.Commit()
···
2149
2228
2150
2229
// notify about the pull merge
2151
2230
for _, p := range pullsToMerge {
2152
-
s.notifier.NewPullMerged(r.Context(), p)
2231
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2153
2232
}
2154
2233
2155
2234
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
···
2210
2289
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2211
2290
return
2212
2291
}
2292
+
p.State = models.PullClosed
2213
2293
}
2214
2294
2215
2295
// Commit the transaction
···
2220
2300
}
2221
2301
2222
2302
for _, p := range pullsToClose {
2223
-
s.notifier.NewPullClosed(r.Context(), p)
2303
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2224
2304
}
2225
2305
2226
2306
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
2282
2362
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2283
2363
return
2284
2364
}
2365
+
p.State = models.PullOpen
2285
2366
}
2286
2367
2287
2368
// Commit the transaction
···
2289
2370
log.Println("failed to commit transaction", err)
2290
2371
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2291
2372
return
2373
+
}
2374
+
2375
+
for _, p := range pullsToReopen {
2376
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2292
2377
}
2293
2378
2294
2379
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
2322
2407
initialSubmission := models.PullSubmission{
2323
2408
Patch: fp.Raw,
2324
2409
SourceRev: fp.SHA,
2410
+
Combined: fp.Raw,
2325
2411
}
2326
2412
pull := models.Pull{
2327
2413
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
+
}
+11
-10
appview/repo/artifact.go
+11
-10
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"
20
13
"tangled.org/core/api/tangled"
21
14
"tangled.org/core/appview/db"
22
15
"tangled.org/core/appview/models"
···
25
18
"tangled.org/core/appview/xrpcclient"
26
19
"tangled.org/core/tid"
27
20
"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"
28
29
)
29
30
30
31
// TODO: proper statuses here on early exit
···
60
61
return
61
62
}
62
63
63
-
uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file)
64
+
uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file)
64
65
if err != nil {
65
66
log.Println("failed to upload blob", err)
66
67
rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.")
···
72
73
rkey := tid.TID()
73
74
createdAt := time.Now()
74
75
75
-
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
76
+
putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
76
77
Collection: tangled.RepoArtifactNSID,
77
78
Repo: user.Did,
78
79
Rkey: rkey,
···
249
250
return
250
251
}
251
252
252
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
253
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
253
254
Collection: tangled.RepoArtifactNSID,
254
255
Repo: user.Did,
255
256
Rkey: artifact.Rkey,
+219
appview/repo/blob.go
+219
appview/repo/blob.go
···
1
+
package repo
2
+
3
+
import (
4
+
"fmt"
5
+
"io"
6
+
"net/http"
7
+
"net/url"
8
+
"path/filepath"
9
+
"slices"
10
+
"strings"
11
+
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/pages"
14
+
"tangled.org/core/appview/pages/markup"
15
+
xrpcclient "tangled.org/core/appview/xrpcclient"
16
+
17
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
18
+
"github.com/go-chi/chi/v5"
19
+
)
20
+
21
+
func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) {
22
+
l := rp.logger.With("handler", "RepoBlob")
23
+
f, err := rp.repoResolver.Resolve(r)
24
+
if err != nil {
25
+
l.Error("failed to get repo and knot", "err", err)
26
+
return
27
+
}
28
+
ref := chi.URLParam(r, "ref")
29
+
ref, _ = url.PathUnescape(ref)
30
+
filePath := chi.URLParam(r, "*")
31
+
filePath, _ = url.PathUnescape(filePath)
32
+
scheme := "http"
33
+
if !rp.config.Core.Dev {
34
+
scheme = "https"
35
+
}
36
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
37
+
xrpcc := &indigoxrpc.Client{
38
+
Host: host,
39
+
}
40
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
41
+
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
42
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
43
+
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
44
+
rp.pages.Error503(w)
45
+
return
46
+
}
47
+
// Use XRPC response directly instead of converting to internal types
48
+
var breadcrumbs [][]string
49
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
50
+
if filePath != "" {
51
+
for idx, elem := range strings.Split(filePath, "/") {
52
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
53
+
}
54
+
}
55
+
showRendered := false
56
+
renderToggle := false
57
+
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
58
+
renderToggle = true
59
+
showRendered = r.URL.Query().Get("code") != "true"
60
+
}
61
+
var unsupported bool
62
+
var isImage bool
63
+
var isVideo bool
64
+
var contentSrc string
65
+
if resp.IsBinary != nil && *resp.IsBinary {
66
+
ext := strings.ToLower(filepath.Ext(resp.Path))
67
+
switch ext {
68
+
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
69
+
isImage = true
70
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
71
+
isVideo = true
72
+
default:
73
+
unsupported = true
74
+
}
75
+
// fetch the raw binary content using sh.tangled.repo.blob xrpc
76
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
77
+
baseURL := &url.URL{
78
+
Scheme: scheme,
79
+
Host: f.Knot,
80
+
Path: "/xrpc/sh.tangled.repo.blob",
81
+
}
82
+
query := baseURL.Query()
83
+
query.Set("repo", repoName)
84
+
query.Set("ref", ref)
85
+
query.Set("path", filePath)
86
+
query.Set("raw", "true")
87
+
baseURL.RawQuery = query.Encode()
88
+
blobURL := baseURL.String()
89
+
contentSrc = blobURL
90
+
if !rp.config.Core.Dev {
91
+
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
92
+
}
93
+
}
94
+
lines := 0
95
+
if resp.IsBinary == nil || !*resp.IsBinary {
96
+
lines = strings.Count(resp.Content, "\n") + 1
97
+
}
98
+
var sizeHint uint64
99
+
if resp.Size != nil {
100
+
sizeHint = uint64(*resp.Size)
101
+
} else {
102
+
sizeHint = uint64(len(resp.Content))
103
+
}
104
+
user := rp.oauth.GetUser(r)
105
+
// Determine if content is binary (dereference pointer)
106
+
isBinary := false
107
+
if resp.IsBinary != nil {
108
+
isBinary = *resp.IsBinary
109
+
}
110
+
rp.pages.RepoBlob(w, pages.RepoBlobParams{
111
+
LoggedInUser: user,
112
+
RepoInfo: f.RepoInfo(user),
113
+
BreadCrumbs: breadcrumbs,
114
+
ShowRendered: showRendered,
115
+
RenderToggle: renderToggle,
116
+
Unsupported: unsupported,
117
+
IsImage: isImage,
118
+
IsVideo: isVideo,
119
+
ContentSrc: contentSrc,
120
+
RepoBlob_Output: resp,
121
+
Contents: resp.Content,
122
+
Lines: lines,
123
+
SizeHint: sizeHint,
124
+
IsBinary: isBinary,
125
+
})
126
+
}
127
+
128
+
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
129
+
l := rp.logger.With("handler", "RepoBlobRaw")
130
+
f, err := rp.repoResolver.Resolve(r)
131
+
if err != nil {
132
+
l.Error("failed to get repo and knot", "err", err)
133
+
w.WriteHeader(http.StatusBadRequest)
134
+
return
135
+
}
136
+
ref := chi.URLParam(r, "ref")
137
+
ref, _ = url.PathUnescape(ref)
138
+
filePath := chi.URLParam(r, "*")
139
+
filePath, _ = url.PathUnescape(filePath)
140
+
scheme := "http"
141
+
if !rp.config.Core.Dev {
142
+
scheme = "https"
143
+
}
144
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
145
+
baseURL := &url.URL{
146
+
Scheme: scheme,
147
+
Host: f.Knot,
148
+
Path: "/xrpc/sh.tangled.repo.blob",
149
+
}
150
+
query := baseURL.Query()
151
+
query.Set("repo", repo)
152
+
query.Set("ref", ref)
153
+
query.Set("path", filePath)
154
+
query.Set("raw", "true")
155
+
baseURL.RawQuery = query.Encode()
156
+
blobURL := baseURL.String()
157
+
req, err := http.NewRequest("GET", blobURL, nil)
158
+
if err != nil {
159
+
l.Error("failed to create request", "err", err)
160
+
return
161
+
}
162
+
// forward the If-None-Match header
163
+
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
164
+
req.Header.Set("If-None-Match", clientETag)
165
+
}
166
+
client := &http.Client{}
167
+
resp, err := client.Do(req)
168
+
if err != nil {
169
+
l.Error("failed to reach knotserver", "err", err)
170
+
rp.pages.Error503(w)
171
+
return
172
+
}
173
+
defer resp.Body.Close()
174
+
// forward 304 not modified
175
+
if resp.StatusCode == http.StatusNotModified {
176
+
w.WriteHeader(http.StatusNotModified)
177
+
return
178
+
}
179
+
if resp.StatusCode != http.StatusOK {
180
+
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
181
+
w.WriteHeader(resp.StatusCode)
182
+
_, _ = io.Copy(w, resp.Body)
183
+
return
184
+
}
185
+
contentType := resp.Header.Get("Content-Type")
186
+
body, err := io.ReadAll(resp.Body)
187
+
if err != nil {
188
+
l.Error("error reading response body from knotserver", "err", err)
189
+
w.WriteHeader(http.StatusInternalServerError)
190
+
return
191
+
}
192
+
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
193
+
// serve all textual content as text/plain
194
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
195
+
w.Write(body)
196
+
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
197
+
// serve images and videos with their original content type
198
+
w.Header().Set("Content-Type", contentType)
199
+
w.Write(body)
200
+
} else {
201
+
w.WriteHeader(http.StatusUnsupportedMediaType)
202
+
w.Write([]byte("unsupported content type"))
203
+
return
204
+
}
205
+
}
206
+
207
+
func isTextualMimeType(mimeType string) bool {
208
+
textualTypes := []string{
209
+
"application/json",
210
+
"application/xml",
211
+
"application/yaml",
212
+
"application/x-yaml",
213
+
"application/toml",
214
+
"application/javascript",
215
+
"application/ecmascript",
216
+
"message/",
217
+
}
218
+
return slices.Contains(textualTypes, mimeType)
219
+
}
+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) RepoAtomFeed(w http.ResponseWriter, r *http.Request) {
149
+
func (rp *Repo) AtomFeed(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)
+32
-18
appview/repo/index.go
+32
-18
appview/repo/index.go
···
3
3
import (
4
4
"errors"
5
5
"fmt"
6
-
"log"
6
+
"log/slog"
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) RepoIndex(w http.ResponseWriter, r *http.Request) {
33
+
func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) {
34
+
l := rp.logger.With("handler", "RepoIndex")
35
+
34
36
ref := chi.URLParam(r, "ref")
35
37
ref, _ = url.PathUnescape(ref)
36
38
37
39
f, err := rp.repoResolver.Resolve(r)
38
40
if err != nil {
39
-
log.Println("failed to fully resolve repo", err)
41
+
l.Error("failed to fully resolve repo", "err", err)
40
42
return
41
43
}
42
44
···
56
58
result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
57
59
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
58
60
if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) {
59
-
log.Println("failed to call XRPC repo.index", err)
61
+
l.Error("failed to call XRPC repo.index", "err", err)
60
62
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
61
63
LoggedInUser: user,
62
64
NeedsKnotUpgrade: true,
···
66
68
}
67
69
68
70
rp.pages.Error503(w)
69
-
log.Println("failed to build index response", err)
71
+
l.Error("failed to build index response", "err", err)
70
72
return
71
73
}
72
74
···
119
121
emails := uniqueEmails(commitsTrunc)
120
122
emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
121
123
if err != nil {
122
-
log.Println("failed to get email to did map", err)
124
+
l.Error("failed to get email to did map", "err", err)
123
125
}
124
126
125
127
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc)
126
128
if err != nil {
127
-
log.Println(err)
129
+
l.Error("failed to GetVerifiedObjectCommits", "err", err)
128
130
}
129
131
130
132
// TODO: a bit dirty
131
-
languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "")
133
+
languageInfo, err := rp.getLanguageInfo(r.Context(), l, f, xrpcc, result.Ref, ref == "")
132
134
if err != nil {
133
-
log.Printf("failed to compute language percentages: %s", err)
135
+
l.Warn("failed to compute language percentages", "err", err)
134
136
// non-fatal
135
137
}
136
138
···
140
142
}
141
143
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
142
144
if err != nil {
143
-
log.Printf("failed to fetch pipeline statuses: %s", err)
145
+
l.Error("failed to fetch pipeline statuses", "err", err)
144
146
// non-fatal
145
147
}
146
148
···
152
154
CommitsTrunc: commitsTrunc,
153
155
TagsTrunc: tagsTrunc,
154
156
// ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands
155
-
BranchesTrunc: branchesTrunc,
156
-
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
157
-
VerifiedCommits: vc,
158
-
Languages: languageInfo,
159
-
Pipelines: pipelines,
157
+
BranchesTrunc: branchesTrunc,
158
+
EmailToDid: emailToDidMap,
159
+
VerifiedCommits: vc,
160
+
Languages: languageInfo,
161
+
Pipelines: pipelines,
160
162
})
161
163
}
162
164
163
165
func (rp *Repo) getLanguageInfo(
164
166
ctx context.Context,
167
+
l *slog.Logger,
165
168
f *reporesolver.ResolvedRepo,
166
169
xrpcc *indigoxrpc.Client,
167
170
currentRef string,
···
180
183
ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo)
181
184
if err != nil {
182
185
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
183
-
log.Println("failed to call XRPC repo.languages", xrpcerr)
186
+
l.Error("failed to call XRPC repo.languages", "err", xrpcerr)
184
187
return nil, xrpcerr
185
188
}
186
189
return nil, err
···
200
203
})
201
204
}
202
205
206
+
tx, err := rp.db.Begin()
207
+
if err != nil {
208
+
return nil, err
209
+
}
210
+
defer tx.Rollback()
211
+
203
212
// update appview's cache
204
-
err = db.InsertRepoLanguages(rp.db, langs)
213
+
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
205
214
if err != nil {
206
215
// non-fatal
207
-
log.Println("failed to cache lang results", err)
216
+
l.Error("failed to cache lang results", "err", err)
217
+
}
218
+
219
+
err = tx.Commit()
220
+
if err != nil {
221
+
return nil, err
208
222
}
209
223
}
210
224
+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
+
}
+65
-1348
appview/repo/repo.go
+65
-1348
appview/repo/repo.go
···
3
3
import (
4
4
"context"
5
5
"database/sql"
6
-
"encoding/json"
7
6
"errors"
8
7
"fmt"
9
-
"io"
10
-
"log"
11
8
"log/slog"
12
9
"net/http"
13
10
"net/url"
14
-
"path/filepath"
15
11
"slices"
16
-
"strconv"
17
12
"strings"
18
13
"time"
19
14
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"
23
15
"tangled.org/core/api/tangled"
24
-
"tangled.org/core/appview/commitverify"
25
16
"tangled.org/core/appview/config"
26
17
"tangled.org/core/appview/db"
27
18
"tangled.org/core/appview/models"
28
19
"tangled.org/core/appview/notify"
29
20
"tangled.org/core/appview/oauth"
30
21
"tangled.org/core/appview/pages"
31
-
"tangled.org/core/appview/pages/markup"
32
22
"tangled.org/core/appview/reporesolver"
33
23
"tangled.org/core/appview/validator"
34
24
xrpcclient "tangled.org/core/appview/xrpcclient"
35
25
"tangled.org/core/eventconsumer"
36
26
"tangled.org/core/idresolver"
37
-
"tangled.org/core/patchutil"
38
27
"tangled.org/core/rbac"
39
28
"tangled.org/core/tid"
40
-
"tangled.org/core/types"
41
29
"tangled.org/core/xrpc/serviceauth"
42
30
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"
43
35
securejoin "github.com/cyphar/filepath-securejoin"
44
36
"github.com/go-chi/chi/v5"
45
-
"github.com/go-git/go-git/v5/plumbing"
46
-
47
-
"github.com/bluesky-social/indigo/atproto/syntax"
48
37
)
49
38
50
39
type Repo struct {
···
89
78
}
90
79
}
91
80
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
81
// 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
82
861
83
// modify the spindle configured for this repo
862
84
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
863
85
user := rp.oauth.GetUser(r)
864
86
l := rp.logger.With("handler", "EditSpindle")
865
87
l = l.With("did", user.Did)
866
-
l = l.With("handle", user.Handle)
867
88
868
89
errorId := "operation-error"
869
90
fail := func(msg string, err error) {
···
916
137
return
917
138
}
918
139
919
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
140
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
920
141
if err != nil {
921
142
fail("Failed to update spindle, no record found on PDS.", err)
922
143
return
923
144
}
924
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
145
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
925
146
Collection: tangled.RepoNSID,
926
147
Repo: newRepo.Did,
927
148
Rkey: newRepo.Rkey,
···
951
172
user := rp.oauth.GetUser(r)
952
173
l := rp.logger.With("handler", "AddLabel")
953
174
l = l.With("did", user.Did)
954
-
l = l.With("handle", user.Handle)
955
175
956
176
f, err := rp.repoResolver.Resolve(r)
957
177
if err != nil {
···
1020
240
1021
241
// emit a labelRecord
1022
242
labelRecord := label.AsRecord()
1023
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
243
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1024
244
Collection: tangled.LabelDefinitionNSID,
1025
245
Repo: label.Did,
1026
246
Rkey: label.Rkey,
···
1043
263
newRepo.Labels = append(newRepo.Labels, aturi)
1044
264
repoRecord := newRepo.AsRecord()
1045
265
1046
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
266
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1047
267
if err != nil {
1048
268
fail("Failed to update labels, no record found on PDS.", err)
1049
269
return
1050
270
}
1051
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
271
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1052
272
Collection: tangled.RepoNSID,
1053
273
Repo: newRepo.Did,
1054
274
Rkey: newRepo.Rkey,
···
1111
331
user := rp.oauth.GetUser(r)
1112
332
l := rp.logger.With("handler", "DeleteLabel")
1113
333
l = l.With("did", user.Did)
1114
-
l = l.With("handle", user.Handle)
1115
334
1116
335
f, err := rp.repoResolver.Resolve(r)
1117
336
if err != nil {
···
1141
360
}
1142
361
1143
362
// delete label record from PDS
1144
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
363
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
1145
364
Collection: tangled.LabelDefinitionNSID,
1146
365
Repo: label.Did,
1147
366
Rkey: label.Rkey,
···
1163
382
newRepo.Labels = updated
1164
383
repoRecord := newRepo.AsRecord()
1165
384
1166
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
385
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1167
386
if err != nil {
1168
387
fail("Failed to update labels, no record found on PDS.", err)
1169
388
return
1170
389
}
1171
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
390
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1172
391
Collection: tangled.RepoNSID,
1173
392
Repo: newRepo.Did,
1174
393
Rkey: newRepo.Rkey,
···
1220
439
user := rp.oauth.GetUser(r)
1221
440
l := rp.logger.With("handler", "SubscribeLabel")
1222
441
l = l.With("did", user.Did)
1223
-
l = l.With("handle", user.Handle)
1224
442
1225
443
f, err := rp.repoResolver.Resolve(r)
1226
444
if err != nil {
···
1261
479
return
1262
480
}
1263
481
1264
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
482
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1265
483
if err != nil {
1266
484
fail("Failed to update labels, no record found on PDS.", err)
1267
485
return
1268
486
}
1269
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
487
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1270
488
Collection: tangled.RepoNSID,
1271
489
Repo: newRepo.Did,
1272
490
Rkey: newRepo.Rkey,
···
1307
525
user := rp.oauth.GetUser(r)
1308
526
l := rp.logger.With("handler", "UnsubscribeLabel")
1309
527
l = l.With("did", user.Did)
1310
-
l = l.With("handle", user.Handle)
1311
528
1312
529
f, err := rp.repoResolver.Resolve(r)
1313
530
if err != nil {
···
1350
567
return
1351
568
}
1352
569
1353
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
570
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1354
571
if err != nil {
1355
572
fail("Failed to update labels, no record found on PDS.", err)
1356
573
return
1357
574
}
1358
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
575
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1359
576
Collection: tangled.RepoNSID,
1360
577
Repo: newRepo.Did,
1361
578
Rkey: newRepo.Rkey,
···
1401
618
db.FilterContains("scope", subject.Collection().String()),
1402
619
)
1403
620
if err != nil {
1404
-
log.Println("failed to fetch label defs", err)
621
+
l.Error("failed to fetch label defs", "err", err)
1405
622
return
1406
623
}
1407
624
···
1412
629
1413
630
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1414
631
if err != nil {
1415
-
log.Println("failed to build label state", err)
632
+
l.Error("failed to build label state", "err", err)
1416
633
return
1417
634
}
1418
635
state := states[subject]
···
1449
666
db.FilterContains("scope", subject.Collection().String()),
1450
667
)
1451
668
if err != nil {
1452
-
log.Println("failed to fetch labels", err)
669
+
l.Error("failed to fetch labels", "err", err)
1453
670
return
1454
671
}
1455
672
···
1460
677
1461
678
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1462
679
if err != nil {
1463
-
log.Println("failed to build label state", err)
680
+
l.Error("failed to build label state", "err", err)
1464
681
return
1465
682
}
1466
683
state := states[subject]
···
1479
696
user := rp.oauth.GetUser(r)
1480
697
l := rp.logger.With("handler", "AddCollaborator")
1481
698
l = l.With("did", user.Did)
1482
-
l = l.With("handle", user.Handle)
1483
699
1484
700
f, err := rp.repoResolver.Resolve(r)
1485
701
if err != nil {
···
1526
742
currentUser := rp.oauth.GetUser(r)
1527
743
rkey := tid.TID()
1528
744
createdAt := time.Now()
1529
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
745
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1530
746
Collection: tangled.RepoCollaboratorNSID,
1531
747
Repo: currentUser.Did,
1532
748
Rkey: rkey,
···
1608
824
1609
825
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
1610
826
user := rp.oauth.GetUser(r)
827
+
l := rp.logger.With("handler", "DeleteRepo")
1611
828
1612
829
noticeId := "operation-error"
1613
830
f, err := rp.repoResolver.Resolve(r)
1614
831
if err != nil {
1615
-
log.Println("failed to get repo and knot", err)
832
+
l.Error("failed to get repo and knot", "err", err)
1616
833
return
1617
834
}
1618
835
1619
836
// remove record from pds
1620
-
xrpcClient, err := rp.oauth.AuthorizedClient(r)
837
+
atpClient, err := rp.oauth.AuthorizedClient(r)
1621
838
if err != nil {
1622
-
log.Println("failed to get authorized client", err)
839
+
l.Error("failed to get authorized client", "err", err)
1623
840
return
1624
841
}
1625
-
_, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
842
+
_, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{
1626
843
Collection: tangled.RepoNSID,
1627
844
Repo: user.Did,
1628
845
Rkey: f.Rkey,
1629
846
})
1630
847
if err != nil {
1631
-
log.Printf("failed to delete record: %s", err)
848
+
l.Error("failed to delete record", "err", err)
1632
849
rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
1633
850
return
1634
851
}
1635
-
log.Println("removed repo record ", f.RepoAt().String())
852
+
l.Info("removed repo record", "aturi", f.RepoAt().String())
1636
853
1637
854
client, err := rp.oauth.ServiceClient(
1638
855
r,
···
1641
858
oauth.WithDev(rp.config.Core.Dev),
1642
859
)
1643
860
if err != nil {
1644
-
log.Println("failed to connect to knot server:", err)
861
+
l.Error("failed to connect to knot server", "err", err)
1645
862
return
1646
863
}
1647
864
···
1658
875
rp.pages.Notice(w, noticeId, err.Error())
1659
876
return
1660
877
}
1661
-
log.Println("deleted repo from knot")
878
+
l.Info("deleted repo from knot")
1662
879
1663
880
tx, err := rp.db.BeginTx(r.Context(), nil)
1664
881
if err != nil {
1665
-
log.Println("failed to start tx")
882
+
l.Error("failed to start tx")
1666
883
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
1667
884
return
1668
885
}
···
1670
887
tx.Rollback()
1671
888
err = rp.enforcer.E.LoadPolicy()
1672
889
if err != nil {
1673
-
log.Println("failed to rollback policies")
890
+
l.Error("failed to rollback policies")
1674
891
}
1675
892
}()
1676
893
···
1684
901
did := c[0]
1685
902
rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
1686
903
}
1687
-
log.Println("removed collaborators")
904
+
l.Info("removed collaborators")
1688
905
1689
906
// remove repo RBAC
1690
907
err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
···
1699
916
rp.pages.Notice(w, noticeId, "Failed to update appview")
1700
917
return
1701
918
}
1702
-
log.Println("removed repo from db")
919
+
l.Info("removed repo from db")
1703
920
1704
921
err = tx.Commit()
1705
922
if err != nil {
1706
-
log.Println("failed to commit changes", err)
923
+
l.Error("failed to commit changes", "err", err)
1707
924
http.Error(w, err.Error(), http.StatusInternalServerError)
1708
925
return
1709
926
}
1710
927
1711
928
err = rp.enforcer.E.SavePolicy()
1712
929
if err != nil {
1713
-
log.Println("failed to update ACLs", err)
930
+
l.Error("failed to update ACLs", "err", err)
1714
931
http.Error(w, err.Error(), http.StatusInternalServerError)
1715
932
return
1716
933
}
···
1718
935
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
1719
936
}
1720
937
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]
938
+
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
939
+
l := rp.logger.With("handler", "SyncRepoFork")
1933
940
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)
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) {
2043
941
ref := chi.URLParam(r, "ref")
2044
942
ref, _ = url.PathUnescape(ref)
2045
943
2046
944
user := rp.oauth.GetUser(r)
2047
945
f, err := rp.repoResolver.Resolve(r)
2048
946
if err != nil {
2049
-
log.Printf("failed to resolve source repo: %v", err)
947
+
l.Error("failed to resolve source repo", "err", err)
2050
948
return
2051
949
}
2052
950
···
2090
988
}
2091
989
2092
990
func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
991
+
l := rp.logger.With("handler", "ForkRepo")
992
+
2093
993
user := rp.oauth.GetUser(r)
2094
994
f, err := rp.repoResolver.Resolve(r)
2095
995
if err != nil {
2096
-
log.Printf("failed to resolve source repo: %v", err)
996
+
l.Error("failed to resolve source repo", "err", err)
2097
997
return
2098
998
}
2099
999
···
2129
1029
}
2130
1030
2131
1031
// choose a name for a fork
2132
-
forkName := f.Name
1032
+
forkName := r.FormValue("repo_name")
1033
+
if forkName == "" {
1034
+
rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
1035
+
return
1036
+
}
1037
+
2133
1038
// this check is *only* to see if the forked repo name already exists
2134
1039
// in the user's account.
2135
1040
existingRepo, err := db.GetRepo(
2136
1041
rp.db,
2137
1042
db.FilterEq("did", user.Did),
2138
-
db.FilterEq("name", f.Name),
1043
+
db.FilterEq("name", forkName),
2139
1044
)
2140
1045
if err != nil {
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
+
if !errors.Is(err, sql.ErrNoRows) {
1047
+
l.Error("error fetching existing repo from db", "err", err)
2145
1048
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
2146
1049
return
2147
1050
}
2148
1051
} else if existingRepo != nil {
2149
-
// repo with this name already exists, append random string
2150
-
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1052
+
// repo with this name already exists
1053
+
rp.pages.Notice(w, "repo", "A repository with this name already exists.")
1054
+
return
2151
1055
}
2152
1056
l = l.With("forkName", forkName)
2153
1057
···
2171
1075
Source: sourceAt,
2172
1076
Description: f.Repo.Description,
2173
1077
Created: time.Now(),
2174
-
Labels: models.DefaultLabelDefs(),
1078
+
Labels: rp.config.Label.DefaultLabelDefs,
2175
1079
}
2176
1080
record := repo.AsRecord()
2177
1081
2178
-
xrpcClient, err := rp.oauth.AuthorizedClient(r)
1082
+
atpClient, err := rp.oauth.AuthorizedClient(r)
2179
1083
if err != nil {
2180
1084
l.Error("failed to create xrpcclient", "err", err)
2181
1085
rp.pages.Notice(w, "repo", "Failed to fork repository.")
2182
1086
return
2183
1087
}
2184
1088
2185
-
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1089
+
atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
2186
1090
Collection: tangled.RepoNSID,
2187
1091
Repo: user.Did,
2188
1092
Rkey: rkey,
···
2214
1118
rollback := func() {
2215
1119
err1 := tx.Rollback()
2216
1120
err2 := rp.enforcer.E.LoadPolicy()
2217
-
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
1121
+
err3 := rollbackRecord(context.Background(), aturi, atpClient)
2218
1122
2219
1123
// ignore txn complete errors, this is okay
2220
1124
if errors.Is(err1, sql.ErrTxDone) {
···
2255
1159
2256
1160
err = db.AddRepo(tx, repo)
2257
1161
if err != nil {
2258
-
log.Println(err)
1162
+
l.Error("failed to AddRepo", "err", err)
2259
1163
rp.pages.Notice(w, "repo", "Failed to save repository information.")
2260
1164
return
2261
1165
}
···
2264
1168
p, _ := securejoin.SecureJoin(user.Did, forkName)
2265
1169
err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
2266
1170
if err != nil {
2267
-
log.Println(err)
1171
+
l.Error("failed to add ACLs", "err", err)
2268
1172
rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
2269
1173
return
2270
1174
}
2271
1175
2272
1176
err = tx.Commit()
2273
1177
if err != nil {
2274
-
log.Println("failed to commit changes", err)
1178
+
l.Error("failed to commit changes", "err", err)
2275
1179
http.Error(w, err.Error(), http.StatusInternalServerError)
2276
1180
return
2277
1181
}
2278
1182
2279
1183
err = rp.enforcer.E.SavePolicy()
2280
1184
if err != nil {
2281
-
log.Println("failed to update ACLs", err)
1185
+
l.Error("failed to update ACLs", "err", err)
2282
1186
http.Error(w, err.Error(), http.StatusInternalServerError)
2283
1187
return
2284
1188
}
···
2287
1191
aturi = ""
2288
1192
2289
1193
rp.notifier.NewRepo(r.Context(), repo)
2290
-
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1194
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName))
2291
1195
}
2292
1196
}
2293
1197
2294
1198
// this is used to rollback changes made to the PDS
2295
1199
//
2296
1200
// it is a no-op if the provided ATURI is empty
2297
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
1201
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
2298
1202
if aturi == "" {
2299
1203
return nil
2300
1204
}
···
2305
1209
repo := parsed.Authority().String()
2306
1210
rkey := parsed.RecordKey().String()
2307
1211
2308
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
1212
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
2309
1213
Collection: collection,
2310
1214
Repo: repo,
2311
1215
Rkey: rkey,
2312
1216
})
2313
1217
return err
2314
1218
}
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
-
}
-35
appview/repo/repo_util.go
-35
appview/repo/repo_util.go
···
1
1
package repo
2
2
3
3
import (
4
-
"context"
5
4
"crypto/rand"
6
-
"fmt"
7
5
"math/big"
8
6
"slices"
9
7
"sort"
···
90
88
}
91
89
92
90
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
126
91
}
127
92
128
93
func randomString(n int) string {
+16
-19
appview/repo/router.go
+16
-19
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.RepoIndex)
13
-
r.Get("/feed.atom", rp.RepoAtomFeed)
14
-
r.Get("/commits/{ref}", rp.RepoLog)
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)
15
16
r.Route("/tree/{ref}", func(r chi.Router) {
16
-
r.Get("/", rp.RepoIndex)
17
-
r.Get("/*", rp.RepoTree)
17
+
r.Get("/", rp.Index)
18
+
r.Get("/*", rp.Tree)
18
19
})
19
-
r.Get("/commit/{ref}", rp.RepoCommit)
20
-
r.Get("/branches", rp.RepoBranches)
20
+
r.Get("/commit/{ref}", rp.Commit)
21
+
r.Get("/branches", rp.Branches)
22
+
r.Delete("/branches", rp.DeleteBranch)
21
23
r.Route("/tags", func(r chi.Router) {
22
-
r.Get("/", rp.RepoTags)
24
+
r.Get("/", rp.Tags)
23
25
r.Route("/{tag}", func(r chi.Router) {
24
26
r.Get("/download/{file}", rp.DownloadArtifact)
25
27
···
35
37
})
36
38
})
37
39
})
38
-
r.Get("/blob/{ref}/*", rp.RepoBlob)
40
+
r.Get("/blob/{ref}/*", rp.Blob)
39
41
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
40
42
41
43
// intentionally doesn't use /* as this isn't
···
52
54
})
53
55
54
56
r.Route("/compare", func(r chi.Router) {
55
-
r.Get("/", rp.RepoCompareNew) // start an new comparison
57
+
r.Get("/", rp.CompareNew) // start an new comparison
56
58
57
59
// we have to wildcard here since we want to support GitHub's compare syntax
58
60
// /compare/{ref1}...{ref2}
59
61
// for example:
60
62
// /compare/master...some/feature
61
63
// /compare/master...example.com:another/feature <- this is a fork
62
-
r.Get("/{base}/{head}", rp.RepoCompare)
63
-
r.Get("/*", rp.RepoCompare)
64
+
r.Get("/{base}/{head}", rp.Compare)
65
+
r.Get("/*", rp.Compare)
64
66
})
65
67
66
68
// label panel in issues/pulls/discussions/tasks
···
72
74
// settings routes, needs auth
73
75
r.Group(func(r chi.Router) {
74
76
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
-
})
81
77
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
82
-
r.Get("/", rp.RepoSettings)
78
+
r.Get("/", rp.Settings)
79
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings)
83
80
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
84
81
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
85
82
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
+
}
+107
appview/repo/tree.go
+107
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
+
IsFile: xrpcFile.Is_file,
57
+
IsSubtree: xrpcFile.Is_subtree,
58
+
}
59
+
// Convert last commit info if present
60
+
if xrpcFile.Last_commit != nil {
61
+
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
62
+
file.LastCommit = &types.LastCommitInfo{
63
+
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
64
+
Message: xrpcFile.Last_commit.Message,
65
+
When: commitWhen,
66
+
}
67
+
}
68
+
files[i] = file
69
+
}
70
+
result := types.RepoTreeResponse{
71
+
Ref: xrpcResp.Ref,
72
+
Files: files,
73
+
}
74
+
if xrpcResp.Parent != nil {
75
+
result.Parent = *xrpcResp.Parent
76
+
}
77
+
if xrpcResp.Dotdot != nil {
78
+
result.DotDot = *xrpcResp.Dotdot
79
+
}
80
+
if xrpcResp.Readme != nil {
81
+
result.ReadmeFileName = xrpcResp.Readme.Filename
82
+
result.Readme = xrpcResp.Readme.Contents
83
+
}
84
+
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
85
+
// so we can safely redirect to the "parent" (which is the same file).
86
+
if len(result.Files) == 0 && result.Parent == treePath {
87
+
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
88
+
http.Redirect(w, r, redirectTo, http.StatusFound)
89
+
return
90
+
}
91
+
user := rp.oauth.GetUser(r)
92
+
var breadcrumbs [][]string
93
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
94
+
if treePath != "" {
95
+
for idx, elem := range strings.Split(treePath, "/") {
96
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
97
+
}
98
+
}
99
+
sortFiles(result.Files)
100
+
rp.pages.RepoTree(w, pages.RepoTreeParams{
101
+
LoggedInUser: user,
102
+
BreadCrumbs: breadcrumbs,
103
+
TreePath: treePath,
104
+
RepoInfo: f.RepoInfo(user),
105
+
RepoTreeResponse: result,
106
+
})
107
+
}
+2
appview/reporesolver/resolver.go
+2
appview/reporesolver/resolver.go
+6
-4
appview/settings/settings.go
+6
-4
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"
25
26
lexutil "github.com/bluesky-social/indigo/lex/util"
26
27
"github.com/gliderlabs/ssh"
27
28
"github.com/google/uuid"
···
91
92
user := s.OAuth.GetUser(r)
92
93
did := s.OAuth.GetDid(r)
93
94
94
-
prefs, err := s.Db.GetNotificationPreferences(r.Context(), did)
95
+
prefs, err := db.GetNotificationPreference(s.Db, did)
95
96
if err != nil {
96
97
log.Printf("failed to get notification preferences: %s", err)
97
98
s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.")
···
110
111
did := s.OAuth.GetDid(r)
111
112
112
113
prefs := &models.NotificationPreferences{
113
-
UserDid: did,
114
+
UserDid: syntax.DID(did),
114
115
RepoStarred: r.FormValue("repo_starred") == "on",
115
116
IssueCreated: r.FormValue("issue_created") == "on",
116
117
IssueCommented: r.FormValue("issue_commented") == "on",
···
119
120
PullCommented: r.FormValue("pull_commented") == "on",
120
121
PullMerged: r.FormValue("pull_merged") == "on",
121
122
Followed: r.FormValue("followed") == "on",
123
+
UserMentioned: r.FormValue("user_mentioned") == "on",
122
124
EmailNotifications: r.FormValue("email_notifications") == "on",
123
125
}
124
126
···
470
472
}
471
473
472
474
// store in pds too
473
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
475
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
474
476
Collection: tangled.PublicKeyNSID,
475
477
Repo: did,
476
478
Rkey: rkey,
···
527
529
528
530
if rkey != "" {
529
531
// remove from pds too
530
-
_, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
532
+
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
531
533
Collection: tangled.PublicKeyNSID,
532
534
Repo: did,
533
535
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
+
}
+95
-40
appview/signup/signup.go
+95
-40
appview/signup/signup.go
···
2
2
3
3
import (
4
4
"bufio"
5
+
"context"
5
6
"encoding/json"
6
7
"errors"
7
8
"fmt"
···
20
21
"tangled.org/core/appview/models"
21
22
"tangled.org/core/appview/pages"
22
23
"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
33
32
idResolver *idresolver.Resolver
34
33
pages *pages.Pages
35
34
l *slog.Logger
···
64
63
disallowed := make(map[string]bool)
65
64
66
65
if filepath == "" {
67
-
logger.Debug("no disallowed nicknames file configured")
66
+
logger.Warn("no disallowed nicknames file configured")
68
67
return disallowed
69
68
}
70
69
···
133
132
noticeId := "signup-msg"
134
133
135
134
if err := s.validateCaptcha(cfToken, r); err != nil {
136
-
s.l.Warn("turnstile validation failed", "error", err)
135
+
s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
137
136
s.pages.Notice(w, noticeId, "Captcha validation failed.")
138
137
return
139
138
}
···
218
217
return
219
218
}
220
219
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
-
228
220
if s.cf == nil {
229
221
s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
230
222
s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
231
223
return
232
224
}
233
225
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
-
})
226
+
// Execute signup transactionally with rollback capability
227
+
err = s.executeSignupTransaction(r.Context(), username, password, email, code, w)
241
228
if err != nil {
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.")
229
+
// Error already logged and notice already sent
244
230
return
245
231
}
232
+
}
233
+
}
246
234
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
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
240
+
241
+
success := false
242
+
defer func() {
243
+
if !success {
244
+
s.l.Info("rolling back signup transaction", "username", username, "did", did)
245
+
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
+
}
253
+
}
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
+
}
257
272
}
273
+
}()
258
274
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))
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
281
+
}
262
282
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)
267
-
}
268
-
}()
269
-
return
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
270
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
271
326
}
272
327
273
328
type turnstileResponse struct {
+14
-5
appview/spindles/spindles.go
+14
-5
appview/spindles/spindles.go
···
6
6
"log/slog"
7
7
"net/http"
8
8
"slices"
9
+
"strings"
9
10
"time"
10
11
11
12
"github.com/go-chi/chi/v5"
···
146
147
}
147
148
148
149
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, "/")
149
156
if instance == "" {
150
157
s.Pages.Notice(w, noticeId, "Incomplete form.")
151
158
return
···
189
196
return
190
197
}
191
198
192
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance)
199
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance)
193
200
var exCid *string
194
201
if ex != nil {
195
202
exCid = ex.Cid
196
203
}
197
204
198
205
// re-announce by registering under same rkey
199
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
206
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
200
207
Collection: tangled.SpindleNSID,
201
208
Repo: user.Did,
202
209
Rkey: instance,
···
332
339
return
333
340
}
334
341
335
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
342
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
336
343
Collection: tangled.SpindleNSID,
337
344
Repo: user.Did,
338
345
Rkey: instance,
···
484
491
}
485
492
486
493
member := r.FormValue("member")
494
+
member = strings.TrimPrefix(member, "@")
487
495
if member == "" {
488
496
l.Error("empty member")
489
497
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
542
550
return
543
551
}
544
552
545
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
553
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
546
554
Collection: tangled.SpindleMemberNSID,
547
555
Repo: user.Did,
548
556
Rkey: rkey,
···
613
621
}
614
622
615
623
member := r.FormValue("member")
624
+
member = strings.TrimPrefix(member, "@")
616
625
if member == "" {
617
626
l.Error("empty member")
618
627
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
···
683
692
}
684
693
685
694
// remove from pds
686
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
695
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
687
696
Collection: tangled.SpindleMemberNSID,
688
697
Repo: user.Did,
689
698
Rkey: members[0].Rkey,
+3
-2
appview/state/follow.go
+3
-2
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
29
30
}
30
31
31
32
if currentUser.Did == subjectIdent.DID.String() {
···
43
44
case http.MethodPost:
44
45
createdAt := time.Now().Format(time.RFC3339)
45
46
rkey := tid.TID()
46
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
47
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
47
48
Collection: tangled.GraphFollowNSID,
48
49
Repo: currentUser.Did,
49
50
Rkey: rkey,
···
88
89
return
89
90
}
90
91
91
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
92
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
92
93
Collection: tangled.GraphFollowNSID,
93
94
Repo: currentUser.Did,
94
95
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
+
}
+17
-2
appview/state/knotstream.go
+17
-2
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
+
28
31
knots, err := db.GetRegistrations(
29
32
d,
30
33
db.FilterIsNot("registered", "null"),
···
39
42
srcs[s] = struct{}{}
40
43
}
41
44
42
-
logger := log.New("knotstream")
43
45
cache := cache.New(c.Redis.Addr)
44
46
cursorStore := cursor.NewRedisCursorStore(cache)
45
47
···
172
174
})
173
175
}
174
176
175
-
return db.InsertRepoLanguages(d, langs)
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()
176
191
}
177
192
178
193
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
+
}
+4
-2
appview/state/profile.go
+4
-2
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")
541
542
542
543
var links [5]string
543
544
for i := range 5 {
···
634
635
vanityStats = append(vanityStats, string(v.Kind))
635
636
}
636
637
637
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self")
638
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
638
639
var cid *string
639
640
if ex != nil {
640
641
cid = ex.Cid
641
642
}
642
643
643
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
644
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
644
645
Collection: tangled.ActorProfileNSID,
645
646
Repo: user.Did,
646
647
Rkey: "self",
···
652
653
Location: &profile.Location,
653
654
PinnedRepositories: pinnedRepoStrings,
654
655
Stats: vanityStats[:],
656
+
Pronouns: &profile.Pronouns,
655
657
}},
656
658
SwapRecord: cid,
657
659
})
+11
-9
appview/state/reaction.go
+11
-9
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
-
11
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
+
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 := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
50
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &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
-
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
73
+
reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri)
74
74
if err != nil {
75
-
log.Println("failed to get reaction count for ", subjectUri)
75
+
log.Println("failed to get reactions 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: count,
83
+
Count: reactionMap[reactionKind].Count,
84
+
Users: reactionMap[reactionKind].Users,
84
85
IsReacted: true,
85
86
})
86
87
···
92
93
return
93
94
}
94
95
95
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
96
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
96
97
Collection: tangled.FeedReactionNSID,
97
98
Repo: currentUser.Did,
98
99
Rkey: reaction.Rkey,
···
109
110
// this is not an issue, the firehose event might have already done this
110
111
}
111
112
112
-
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
113
+
reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri)
113
114
if err != nil {
114
-
log.Println("failed to get reaction count for ", subjectUri)
115
+
log.Println("failed to get reactions for ", subjectUri)
115
116
return
116
117
}
117
118
118
119
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
119
120
ThreadAt: subjectUri,
120
121
Kind: reactionKind,
121
-
Count: count,
122
+
Count: reactionMap[reactionKind].Count,
123
+
Users: reactionMap[reactionKind].Users,
122
124
IsReacted: false,
123
125
})
124
126
+112
-59
appview/state/router.go
+112
-59
appview/state/router.go
···
5
5
"strings"
6
6
7
7
"github.com/go-chi/chi/v5"
8
-
"github.com/gorilla/sessions"
9
8
"tangled.org/core/appview/issues"
10
9
"tangled.org/core/appview/knots"
11
10
"tangled.org/core/appview/labels"
12
11
"tangled.org/core/appview/middleware"
13
12
"tangled.org/core/appview/notifications"
14
-
oauthhandler "tangled.org/core/appview/oauth/handler"
15
13
"tangled.org/core/appview/pipelines"
16
14
"tangled.org/core/appview/pulls"
17
15
"tangled.org/core/appview/repo"
···
34
32
s.pages,
35
33
)
36
34
37
-
router.Use(middleware.TryRefreshSession())
38
35
router.Get("/favicon.svg", s.Favicon)
39
36
router.Get("/favicon.ico", s.Favicon)
37
+
router.Get("/pwa-manifest.json", s.PWAManifest)
38
+
router.Get("/robots.txt", s.RobotsTxt)
40
39
41
40
userRouter := s.UserRouter(&middleware)
42
41
standardRouter := s.StandardRouter(&middleware)
43
42
44
43
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
45
44
pat := chi.URLParam(r, "*")
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
-
}
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
69
54
}
70
-
standardRouter.ServeHTTP(w, r)
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
77
+
}
78
+
71
79
}
80
+
81
+
standardRouter.ServeHTTP(w, r)
72
82
})
73
83
74
84
return router
···
81
91
r.Get("/", s.Profile)
82
92
r.Get("/feed.atom", s.AtomFeedPage)
83
93
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
-
90
94
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
91
95
r.Use(mw.GoImport())
92
96
r.Mount("/", s.RepoRouter(mw))
93
97
r.Mount("/issues", s.IssuesRouter(mw))
94
98
r.Mount("/pulls", s.PullsRouter(mw))
95
-
r.Mount("/pipelines", s.PipelinesRouter(mw))
96
-
r.Mount("/labels", s.LabelsRouter(mw))
99
+
r.Mount("/pipelines", s.PipelinesRouter())
100
+
r.Mount("/labels", s.LabelsRouter())
97
101
98
102
// These routes get proxied to the knot
99
103
r.Get("/info/refs", s.InfoRefs)
···
122
126
// special-case handler for serving tangled.org/core
123
127
r.Get("/core", s.Core())
124
128
129
+
r.Get("/login", s.Login)
130
+
r.Post("/login", s.Login)
131
+
r.Post("/logout", s.Logout)
132
+
125
133
r.Route("/repo", func(r chi.Router) {
126
134
r.Route("/new", func(r chi.Router) {
127
135
r.Use(middleware.AuthMiddleware(s.oauth))
···
130
138
})
131
139
// r.Post("/import", s.ImportRepo)
132
140
})
141
+
142
+
r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues)
133
143
134
144
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
135
145
r.Post("/", s.Follow)
···
161
171
r.Mount("/notifications", s.NotificationsRouter(mw))
162
172
163
173
r.Mount("/signup", s.SignupRouter())
164
-
r.Mount("/", s.OAuthRouter())
174
+
r.Mount("/", s.oauth.Router())
165
175
166
176
r.Get("/keys/{user}", s.Keys)
167
177
r.Get("/terms", s.TermsOfService)
···
188
198
}
189
199
}
190
200
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
-
197
201
func (s *State) SettingsRouter() http.Handler {
198
202
settings := &settings.Settings{
199
203
Db: s.db,
···
206
210
}
207
211
208
212
func (s *State) SpindlesRouter() http.Handler {
209
-
logger := log.New("spindles")
213
+
logger := log.SubLogger(s.logger, "spindles")
210
214
211
215
spindles := &spindles.Spindles{
212
216
Db: s.db,
···
222
226
}
223
227
224
228
func (s *State) KnotsRouter() http.Handler {
225
-
logger := log.New("knots")
229
+
logger := log.SubLogger(s.logger, "knots")
226
230
227
231
knots := &knots.Knots{
228
232
Db: s.db,
···
239
243
}
240
244
241
245
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
242
-
logger := log.New("strings")
246
+
logger := log.SubLogger(s.logger, "strings")
243
247
244
248
strs := &avstrings.Strings{
245
249
Db: s.db,
···
254
258
}
255
259
256
260
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
257
-
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator)
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
+
)
258
273
return issues.Router(mw)
259
274
}
260
275
261
276
func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {
262
-
pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
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
+
)
263
290
return pulls.Router(mw)
264
291
}
265
292
266
293
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
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)
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
+
)
269
307
return repo.Router(mw)
270
308
}
271
309
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)
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()
275
323
}
276
324
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)
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()
280
335
}
281
336
282
337
func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
283
-
notifs := notifications.New(s.db, s.oauth, s.pages)
338
+
notifs := notifications.New(s.db, s.oauth, s.pages, log.SubLogger(s.logger, "notifications"))
284
339
return notifs.Router(mw)
285
340
}
286
341
287
342
func (s *State) SignupRouter() http.Handler {
288
-
logger := log.New("signup")
289
-
290
-
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger)
343
+
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup"))
291
344
return sig.Router()
292
345
}
+3
-1
appview/state/spindlestream.go
+3
-1
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
+
25
28
spindles, err := db.GetSpindles(
26
29
d,
27
30
db.FilterIsNot("verified", "null"),
···
36
39
srcs[src] = struct{}{}
37
40
}
38
41
39
-
logger := log.New("spindlestream")
40
42
cache := cache.New(c.Redis.Addr)
41
43
cursorStore := cursor.NewRedisCursorStore(cache)
42
44
+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 := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
43
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &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 = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
95
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
96
96
Collection: tangled.FeedStarNSID,
97
97
Repo: currentUser.Did,
98
98
Rkey: star.Rkey,
+101
-47
appview/state/state.go
+101
-47
appview/state/state.go
···
5
5
"database/sql"
6
6
"errors"
7
7
"fmt"
8
-
"log"
9
8
"log/slog"
10
9
"net/http"
11
10
"strings"
12
11
"time"
13
12
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"
20
13
"tangled.org/core/api/tangled"
21
14
"tangled.org/core/appview"
22
-
"tangled.org/core/appview/cache"
23
-
"tangled.org/core/appview/cache/session"
24
15
"tangled.org/core/appview/config"
25
16
"tangled.org/core/appview/db"
17
+
"tangled.org/core/appview/indexer"
26
18
"tangled.org/core/appview/models"
27
19
"tangled.org/core/appview/notify"
28
20
dbnotify "tangled.org/core/appview/notify/db"
···
35
27
"tangled.org/core/eventconsumer"
36
28
"tangled.org/core/idresolver"
37
29
"tangled.org/core/jetstream"
30
+
"tangled.org/core/log"
38
31
tlog "tangled.org/core/log"
39
32
"tangled.org/core/rbac"
40
33
"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"
41
42
)
42
43
43
44
type State struct {
44
45
db *db.DB
45
46
notifier notify.Notifier
47
+
indexer *indexer.Indexer
46
48
oauth *oauth.OAuth
47
49
enforcer *rbac.Enforcer
48
50
pages *pages.Pages
49
-
sess *session.SessionStore
50
51
idResolver *idresolver.Resolver
51
52
posthog posthog.Client
52
53
jc *jetstream.JetstreamClient
···
59
60
}
60
61
61
62
func Make(ctx context.Context, config *config.Config) (*State, error) {
62
-
d, err := db.Make(config.Core.DbPath)
63
+
logger := tlog.FromContext(ctx)
64
+
65
+
d, err := db.Make(ctx, config.Core.DbPath)
63
66
if err != nil {
64
67
return nil, fmt.Errorf("failed to create db: %w", err)
65
68
}
66
69
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
+
67
76
enforcer, err := rbac.NewEnforcer(config.Core.DbPath)
68
77
if err != nil {
69
78
return nil, fmt.Errorf("failed to create enforcer: %w", err)
70
79
}
71
80
72
-
res, err := idresolver.RedisResolver(config.Redis.ToURL())
81
+
res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL)
73
82
if err != nil {
74
-
log.Printf("failed to create redis resolver: %v", err)
75
-
res = idresolver.DefaultResolver()
83
+
logger.Error("failed to create redis resolver", "err", err)
84
+
res = idresolver.DefaultResolver(config.Plc.PLCURL)
76
85
}
77
86
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
-
84
87
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
85
88
if err != nil {
86
89
return nil, fmt.Errorf("failed to create posthog client: %w", err)
87
90
}
88
91
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
+
89
99
repoResolver := reporesolver.New(config, enforcer, res, d)
90
100
91
101
wrapper := db.DbWrapper{Execer: d}
···
107
117
tangled.LabelOpNSID,
108
118
},
109
119
nil,
110
-
slog.Default(),
120
+
tlog.SubLogger(logger, "jetstream"),
111
121
wrapper,
112
122
false,
113
123
···
119
129
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
120
130
}
121
131
122
-
if err := BackfillDefaultDefs(d, res); err != nil {
132
+
if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil {
123
133
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
124
134
}
125
135
···
128
138
Enforcer: enforcer,
129
139
IdResolver: res,
130
140
Config: config,
131
-
Logger: tlog.New("ingester"),
141
+
Logger: log.SubLogger(logger, "ingester"),
132
142
Validator: validator,
133
143
}
134
144
err = jc.StartJetstream(ctx, ingester.Ingest())
···
157
167
if !config.Core.Dev {
158
168
notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog))
159
169
}
160
-
notifier := notify.NewMergedNotifier(notifiers...)
170
+
notifiers = append(notifiers, indexer)
171
+
notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify"))
161
172
162
173
state := &State{
163
174
d,
164
175
notifier,
176
+
indexer,
165
177
oauth,
166
178
enforcer,
167
-
pgs,
168
-
sess,
179
+
pages,
169
180
res,
170
181
posthog,
171
182
jc,
···
173
184
repoResolver,
174
185
knotstream,
175
186
spindlestream,
176
-
slog.Default(),
187
+
logger,
177
188
validator,
178
189
}
179
190
···
198
209
s.pages.Favicon(w)
199
210
}
200
211
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
+
201
245
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
202
246
user := s.oauth.GetUser(r)
203
247
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
230
274
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
231
275
user := s.oauth.GetUser(r)
232
276
277
+
// TODO: set this flag based on the UI
278
+
filtered := false
279
+
233
280
var userDid string
234
281
if user != nil {
235
282
userDid = user.Did
236
283
}
237
-
timeline, err := db.MakeTimeline(s.db, 50, userDid)
284
+
timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered)
238
285
if err != nil {
239
-
log.Println(err)
286
+
s.logger.Error("failed to make timeline", "err", err)
240
287
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
241
288
}
242
289
243
290
repos, err := db.GetTopStarredReposLastWeek(s.db)
244
291
if err != nil {
245
-
log.Println(err)
292
+
s.logger.Error("failed to get top starred repos", "err", err)
246
293
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
247
294
return
248
295
}
249
296
297
+
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
298
+
if err != nil {
299
+
// non-fatal
300
+
}
301
+
250
302
s.pages.Timeline(w, pages.TimelineParams{
251
303
LoggedInUser: user,
252
304
Timeline: timeline,
253
305
Repos: repos,
306
+
GfiLabel: gfiLabel,
254
307
})
255
308
}
256
309
···
262
315
263
316
l := s.logger.With("handler", "UpgradeBanner")
264
317
l = l.With("did", user.Did)
265
-
l = l.With("handle", user.Handle)
266
318
267
319
regs, err := db.GetRegistrations(
268
320
s.db,
···
293
345
}
294
346
295
347
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
296
-
timeline, err := db.MakeTimeline(s.db, 5, "")
348
+
// TODO: set this flag based on the UI
349
+
filtered := false
350
+
351
+
timeline, err := db.MakeTimeline(s.db, 5, "", filtered)
297
352
if err != nil {
298
-
log.Println(err)
353
+
s.logger.Error("failed to make timeline", "err", err)
299
354
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
300
355
return
301
356
}
302
357
303
358
repos, err := db.GetTopStarredReposLastWeek(s.db)
304
359
if err != nil {
305
-
log.Println(err)
360
+
s.logger.Error("failed to get top starred repos", "err", err)
306
361
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
307
362
return
308
363
}
···
331
386
332
387
pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String())
333
388
if err != nil {
334
-
w.WriteHeader(http.StatusNotFound)
389
+
s.logger.Error("failed to get public keys", "err", err)
390
+
http.Error(w, "failed to get public keys", http.StatusInternalServerError)
335
391
return
336
392
}
337
393
338
394
if len(pubKeys) == 0 {
339
-
w.WriteHeader(http.StatusNotFound)
395
+
w.WriteHeader(http.StatusNoContent)
340
396
return
341
397
}
342
398
···
402
458
403
459
user := s.oauth.GetUser(r)
404
460
l = l.With("did", user.Did)
405
-
l = l.With("handle", user.Handle)
406
461
407
462
// form validation
408
463
domain := r.FormValue("domain")
···
462
517
Rkey: rkey,
463
518
Description: description,
464
519
Created: time.Now(),
465
-
Labels: models.DefaultLabelDefs(),
520
+
Labels: s.config.Label.DefaultLabelDefs,
466
521
}
467
522
record := repo.AsRecord()
468
523
469
-
xrpcClient, err := s.oauth.AuthorizedClient(r)
524
+
atpClient, err := s.oauth.AuthorizedClient(r)
470
525
if err != nil {
471
526
l.Info("PDS write failed", "err", err)
472
527
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
473
528
return
474
529
}
475
530
476
-
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
531
+
atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
477
532
Collection: tangled.RepoNSID,
478
533
Repo: user.Did,
479
534
Rkey: rkey,
···
505
560
rollback := func() {
506
561
err1 := tx.Rollback()
507
562
err2 := s.enforcer.E.LoadPolicy()
508
-
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
563
+
err3 := rollbackRecord(context.Background(), aturi, atpClient)
509
564
510
565
// ignore txn complete errors, this is okay
511
566
if errors.Is(err1, sql.ErrTxDone) {
···
578
633
aturi = ""
579
634
580
635
s.notifier.NewRepo(r.Context(), repo)
581
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
636
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName))
582
637
}
583
638
}
584
639
585
640
// this is used to rollback changes made to the PDS
586
641
//
587
642
// it is a no-op if the provided ATURI is empty
588
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
643
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
589
644
if aturi == "" {
590
645
return nil
591
646
}
···
596
651
repo := parsed.Authority().String()
597
652
rkey := parsed.RecordKey().String()
598
653
599
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
654
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
600
655
Collection: collection,
601
656
Repo: repo,
602
657
Rkey: rkey,
···
604
659
return err
605
660
}
606
661
607
-
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error {
608
-
defaults := models.DefaultLabelDefs()
662
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
609
663
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
610
664
if err != nil {
611
665
return err
···
615
669
return nil
616
670
}
617
671
618
-
labelDefs, err := models.FetchDefaultDefs(r)
672
+
labelDefs, err := models.FetchLabelDefs(r, defaults)
619
673
if err != nil {
620
674
return err
621
675
}
+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 IsHandleNoAt(s string) bool {
13
+
func IsHandle(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)
16
21
}
17
22
18
23
func UnflattenDid(s string) string {
···
45
50
return strings.Replace(s, ":", "-", 2)
46
51
}
47
52
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])?$`)
+9
-7
appview/strings/strings.go
+9
-7
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
-
lexutil "github.com/bluesky-social/indigo/lex/util"
26
25
"github.com/go-chi/chi/v5"
26
+
27
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
28
+
lexutil "github.com/bluesky-social/indigo/lex/util"
27
29
)
28
30
29
31
type Strings struct {
···
254
256
}
255
257
256
258
// first replace the existing record in the PDS
257
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
259
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
258
260
if err != nil {
259
261
fail("Failed to updated existing record.", err)
260
262
return
261
263
}
262
-
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
264
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
263
265
Collection: tangled.StringNSID,
264
266
Repo: entry.Did.String(),
265
267
Rkey: entry.Rkey,
···
284
286
s.Notifier.EditString(r.Context(), &entry)
285
287
286
288
// if that went okay, redir to the string
287
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
289
+
s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey)
288
290
}
289
291
290
292
}
···
336
338
return
337
339
}
338
340
339
-
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
341
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
340
342
Collection: tangled.StringNSID,
341
343
Repo: user.Did,
342
344
Rkey: string.Rkey,
···
360
362
s.Notifier.NewString(r.Context(), &string)
361
363
362
364
// successful
363
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
365
+
s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey)
364
366
}
365
367
}
366
368
···
403
405
404
406
s.Notifier.DeleteString(r.Context(), user.Did, rkey)
405
407
406
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
408
+
s.Pages.HxRedirect(w, "/strings/"+user.Did)
407
409
}
408
410
409
411
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"
6
4
"errors"
7
-
"io"
8
5
"net/http"
9
6
10
-
"github.com/bluesky-social/indigo/api/atproto"
11
-
"github.com/bluesky-social/indigo/xrpc"
12
7
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
13
-
oauth "tangled.sh/icyphox.sh/atproto-oauth"
14
8
)
15
9
16
10
var (
···
19
13
ErrXrpcFailed = errors.New("xrpc request failed")
20
14
ErrXrpcInvalid = errors.New("invalid xrpc request")
21
15
)
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
-
}
115
16
116
17
// produces a more manageable error
117
18
func HandleXrpcErr(err error) error {
+14
-9
cmd/appview/main.go
+14
-9
cmd/appview/main.go
···
2
2
3
3
import (
4
4
"context"
5
-
"log"
6
-
"log/slog"
7
5
"net/http"
8
6
"os"
9
7
10
8
"tangled.org/core/appview/config"
11
9
"tangled.org/core/appview/state"
10
+
tlog "tangled.org/core/log"
12
11
)
13
12
14
13
func main() {
15
-
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil)))
16
-
17
14
ctx := context.Background()
15
+
logger := tlog.New("appview")
16
+
ctx = tlog.IntoContext(ctx, logger)
18
17
19
18
c, err := config.LoadConfig(ctx)
20
19
if err != nil {
21
-
log.Println("failed to load config", "error", err)
20
+
logger.Error("failed to load config", "error", err)
22
21
return
23
22
}
24
23
25
24
state, err := state.Make(ctx, c)
26
25
defer func() {
27
-
log.Println(state.Close())
26
+
if err := state.Close(); err != nil {
27
+
logger.Error("failed to close state", "err", err)
28
+
}
28
29
}()
29
30
30
31
if err != nil {
31
-
log.Fatal(err)
32
+
logger.Error("failed to start appview", "err", err)
33
+
os.Exit(-1)
32
34
}
33
35
34
-
log.Println("starting server on", c.Core.ListenAddr)
35
-
log.Println(http.ListenAndServe(c.Core.ListenAddr, state.Router()))
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
+
}
36
41
}
+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
-
}
+6
-3
cmd/knot/main.go
+6
-3
cmd/knot/main.go
···
2
2
3
3
import (
4
4
"context"
5
+
"log/slog"
5
6
"os"
6
7
7
8
"github.com/urfave/cli/v3"
···
9
10
"tangled.org/core/hook"
10
11
"tangled.org/core/keyfetch"
11
12
"tangled.org/core/knotserver"
12
-
"tangled.org/core/log"
13
+
tlog "tangled.org/core/log"
13
14
)
14
15
15
16
func main() {
···
24
25
},
25
26
}
26
27
28
+
logger := tlog.New("knot")
29
+
slog.SetDefault(logger)
30
+
27
31
ctx := context.Background()
28
-
logger := log.New("knot")
29
-
ctx = log.IntoContext(ctx, logger.With("command", cmd.Name))
32
+
ctx = tlog.IntoContext(ctx, logger)
30
33
31
34
if err := cmd.Run(ctx, os.Args); err != nil {
32
35
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
-
}
+9
-4
cmd/spindle/main.go
+9
-4
cmd/spindle/main.go
···
2
2
3
3
import (
4
4
"context"
5
+
"log/slog"
5
6
"os"
6
7
7
-
"tangled.org/core/log"
8
+
tlog "tangled.org/core/log"
8
9
"tangled.org/core/spindle"
9
-
_ "tangled.org/core/tid"
10
10
)
11
11
12
12
func main() {
13
-
ctx := log.NewContext(context.Background(), "spindle")
13
+
logger := tlog.New("spindle")
14
+
slog.SetDefault(logger)
15
+
16
+
ctx := context.Background()
17
+
ctx = tlog.IntoContext(ctx, logger)
18
+
14
19
err := spindle.Run(ctx)
15
20
if err != nil {
16
-
log.FromContext(ctx).Error("error running spindle", "error", err)
21
+
logger.Error("error running spindle", "error", err)
17
22
os.Exit(-1)
18
23
}
19
24
}
+16
-6
docs/hacking.md
+16
-6
docs/hacking.md
···
37
37
38
38
```
39
39
# oauth jwks should already be setup by the nix devshell:
40
-
echo $TANGLED_OAUTH_JWKS
41
-
{"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"}
40
+
echo $TANGLED_OAUTH_CLIENT_SECRET
41
+
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
42
+
43
+
echo $TANGLED_OAUTH_CLIENT_KID
44
+
1761667908
42
45
43
46
# if not, you can set it up yourself:
44
-
go build -o genjwks.out ./cmd/genjwks
45
-
export TANGLED_OAUTH_JWKS="$(./genjwks.out)"
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..."
46
56
47
57
# run redis in at a new shell to store oauth sessions
48
58
redis-server
···
158
168
159
169
If for any reason you wish to disable either one of the
160
170
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
161
-
`services.tangled-spindle.enable` (or
162
-
`services.tangled-knot.enable`) to `false`.
171
+
`services.tangled.spindle.enable` (or
172
+
`services.tangled.knot.enable`) to `false`.
+2
-1
docs/knot-hosting.md
+2
-1
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/knot` is a good choice:
42
+
`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
43
43
44
44
```
45
45
sudo mv knot /usr/local/bin/knot
46
+
sudo chown root:root /usr/local/bin/knot
46
47
```
47
48
48
49
This is necessary because SSH `AuthorizedKeysCommand` requires [really
+1
-1
docs/migrations.md
+1
-1
docs/migrations.md
+20
-2
docs/spindle/pipeline.md
+20
-2
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`: 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.
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.
23
24
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:
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:
25
26
26
27
```yaml
27
28
when:
···
29
30
branch: ["main", "develop"]
30
31
- event: ["pull_request"]
31
32
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"]
32
50
```
33
51
34
52
## 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
+
},
3
19
"flake-compat": {
4
20
"flake": false,
5
21
"locked": {
···
150
166
},
151
167
"root": {
152
168
"inputs": {
169
+
"actor-typeahead-src": "actor-typeahead-src",
153
170
"flake-compat": "flake-compat",
154
171
"gomod2nix": "gomod2nix",
155
172
"htmx-src": "htmx-src",
+19
-9
flake.nix
+19
-9
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
+
};
36
40
ibm-plex-mono-src = {
37
41
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
38
42
flake = false;
···
54
58
inter-fonts-src,
55
59
sqlite-lib-src,
56
60
ibm-plex-mono-src,
61
+
actor-typeahead-src,
57
62
...
58
63
}: let
59
64
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
···
78
83
inherit (pkgs) gcc;
79
84
inherit sqlite-lib-src;
80
85
};
81
-
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
82
86
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
87
+
goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;};
83
88
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
84
-
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
89
+
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
85
90
};
86
91
appview = self.callPackage ./nix/pkgs/appview.nix {};
87
92
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
···
90
95
});
91
96
in {
92
97
overlays.default = final: prev: {
93
-
inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview;
98
+
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview;
94
99
};
95
100
96
101
packages = forAllSystems (system: let
···
99
104
staticPackages = mkPackageSet pkgs.pkgsStatic;
100
105
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
101
106
in {
102
-
inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib;
107
+
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib;
103
108
104
109
pkgsStatic-appview = staticPackages.appview;
105
110
pkgsStatic-knot = staticPackages.knot;
···
167
172
mkdir -p appview/pages/static
168
173
# no preserve is needed because watch-tailwind will want to be able to overwrite
169
174
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
170
-
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
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}')"
171
177
'';
172
178
env.CGO_ENABLED = 1;
173
179
};
···
206
212
watch-knot = {
207
213
type = "app";
208
214
program = ''${air-watcher "knot" "server"}/bin/run'';
215
+
};
216
+
watch-spindle = {
217
+
type = "app";
218
+
program = ''${air-watcher "spindle" ""}/bin/run'';
209
219
};
210
220
watch-tailwind = {
211
221
type = "app";
···
262
272
lexgen --build-file lexicon-build-config.json lexicons
263
273
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
264
274
${pkgs.gotools}/bin/goimports -w api/tangled/*
265
-
go run cmd/gen.go
275
+
go run ./cmd/cborgen/
266
276
lexgen --build-file lexicon-build-config.json lexicons
267
277
rm api/tangled/*.bak
268
278
'';
···
278
288
}: {
279
289
imports = [./nix/modules/appview.nix];
280
290
281
-
services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
291
+
services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
282
292
};
283
293
nixosModules.knot = {
284
294
lib,
···
287
297
}: {
288
298
imports = [./nix/modules/knot.nix];
289
299
290
-
services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
300
+
services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
291
301
};
292
302
nixosModules.spindle = {
293
303
lib,
···
296
306
}: {
297
307
imports = [./nix/modules/spindle.nix];
298
308
299
-
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
309
+
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
300
310
};
301
311
};
302
312
}
+53
-9
go.mod
+53
-9
go.mod
···
8
8
github.com/alecthomas/chroma/v2 v2.15.0
9
9
github.com/avast/retry-go/v4 v4.6.1
10
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
-
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb
11
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
12
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
13
github.com/carlmjohnson/versioninfo v0.22.5
14
14
github.com/casbin/casbin/v2 v2.103.0
···
21
21
github.com/go-chi/chi/v5 v5.2.0
22
22
github.com/go-enry/go-enry/v2 v2.9.2
23
23
github.com/go-git/go-git/v5 v5.14.0
24
+
github.com/goki/freetype v1.0.5
24
25
github.com/google/uuid v1.6.0
25
26
github.com/gorilla/feeds v1.2.0
26
27
github.com/gorilla/sessions v1.4.0
···
36
37
github.com/redis/go-redis/v9 v9.7.3
37
38
github.com/resend/resend-go/v2 v2.15.0
38
39
github.com/sethvargo/go-envconfig v1.1.0
40
+
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
41
+
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
39
42
github.com/stretchr/testify v1.10.0
40
43
github.com/urfave/cli/v3 v3.3.3
41
44
github.com/whyrusleeping/cbor-gen v0.3.1
42
-
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57
43
-
github.com/yuin/goldmark v1.7.12
45
+
github.com/wyatt915/goldmark-treeblood v0.0.1
46
+
github.com/yuin/goldmark v1.7.13
44
47
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
45
48
golang.org/x/crypto v0.40.0
49
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
50
+
golang.org/x/image v0.31.0
46
51
golang.org/x/net v0.42.0
47
-
golang.org/x/sync v0.16.0
52
+
golang.org/x/sync v0.17.0
48
53
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
49
54
gopkg.in/yaml.v3 v3.0.1
50
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1
51
55
)
52
56
53
57
require (
54
58
dario.cat/mergo v1.0.1 // indirect
55
59
github.com/Microsoft/go-winio v0.6.2 // indirect
56
60
github.com/ProtonMail/go-crypto v1.3.0 // indirect
61
+
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
57
62
github.com/alecthomas/repr v0.4.0 // indirect
58
63
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
64
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
59
65
github.com/aymerick/douceur v0.2.0 // indirect
60
66
github.com/beorn7/perks v1.0.1 // indirect
61
-
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
67
+
github.com/bits-and-blooms/bitset v1.22.0 // indirect
68
+
github.com/blevesearch/bleve/v2 v2.5.3 // indirect
69
+
github.com/blevesearch/bleve_index_api v1.2.8 // indirect
70
+
github.com/blevesearch/geo v0.2.4 // indirect
71
+
github.com/blevesearch/go-faiss v1.0.25 // indirect
72
+
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
73
+
github.com/blevesearch/gtreap v0.1.1 // indirect
74
+
github.com/blevesearch/mmap-go v1.0.4 // indirect
75
+
github.com/blevesearch/scorch_segment_api/v2 v2.3.10 // indirect
76
+
github.com/blevesearch/segment v0.9.1 // indirect
77
+
github.com/blevesearch/snowballstem v0.9.0 // indirect
78
+
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
79
+
github.com/blevesearch/vellum v1.1.0 // indirect
80
+
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
81
+
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
82
+
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
83
+
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
84
+
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
85
+
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
86
+
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
62
87
github.com/casbin/govaluate v1.3.0 // indirect
63
88
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
64
89
github.com/cespare/xxhash/v2 v2.3.0 // indirect
90
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
91
+
github.com/charmbracelet/lipgloss v1.1.0 // indirect
92
+
github.com/charmbracelet/log v0.4.2 // 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
65
96
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect
66
97
github.com/containerd/errdefs v1.0.0 // indirect
67
98
github.com/containerd/errdefs/pkg v0.3.0 // indirect
···
80
111
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
81
112
github.com/go-git/go-billy/v5 v5.6.2 // indirect
82
113
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
114
+
github.com/go-logfmt/logfmt v0.6.0 // indirect
83
115
github.com/go-logr/logr v1.4.3 // indirect
84
116
github.com/go-logr/stdr v1.2.2 // indirect
85
117
github.com/go-redis/cache/v9 v9.0.0 // indirect
···
89
121
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
90
122
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
91
123
github.com/golang/mock v1.6.0 // indirect
124
+
github.com/golang/protobuf v1.5.4 // indirect
125
+
github.com/golang/snappy v0.0.4 // indirect
92
126
github.com/google/go-querystring v1.1.0 // indirect
93
127
github.com/gorilla/css v1.0.1 // indirect
94
128
github.com/gorilla/securecookie v1.1.2 // indirect
···
114
148
github.com/ipfs/go-log v1.0.5 // indirect
115
149
github.com/ipfs/go-log/v2 v2.6.0 // indirect
116
150
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
151
+
github.com/json-iterator/go v1.1.12 // indirect
117
152
github.com/kevinburke/ssh_config v1.2.0 // indirect
118
153
github.com/klauspost/compress v1.18.0 // indirect
119
154
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
···
122
157
github.com/lestrrat-go/httprc v1.0.6 // indirect
123
158
github.com/lestrrat-go/iter v1.0.2 // indirect
124
159
github.com/lestrrat-go/option v1.0.1 // indirect
160
+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
125
161
github.com/mattn/go-isatty v0.0.20 // indirect
162
+
github.com/mattn/go-runewidth v0.0.16 // indirect
126
163
github.com/minio/sha256-simd v1.0.1 // indirect
127
164
github.com/mitchellh/mapstructure v1.5.0 // indirect
128
165
github.com/moby/docker-image-spec v1.3.1 // indirect
129
166
github.com/moby/sys/atomicwriter v0.1.0 // indirect
130
167
github.com/moby/term v0.5.2 // indirect
168
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
169
+
github.com/modern-go/reflect2 v1.0.2 // indirect
131
170
github.com/morikuni/aec v1.0.0 // indirect
132
171
github.com/mr-tron/base58 v1.2.0 // indirect
172
+
github.com/mschoch/smat v0.2.0 // indirect
173
+
github.com/muesli/termenv v0.16.0 // indirect
133
174
github.com/multiformats/go-base32 v0.1.0 // indirect
134
175
github.com/multiformats/go-base36 v0.2.0 // indirect
135
176
github.com/multiformats/go-multibase v0.2.0 // indirect
···
148
189
github.com/prometheus/client_model v0.6.2 // indirect
149
190
github.com/prometheus/common v0.64.0 // indirect
150
191
github.com/prometheus/procfs v0.16.1 // indirect
192
+
github.com/rivo/uniseg v0.4.7 // indirect
151
193
github.com/ryanuber/go-glob v1.0.0 // indirect
152
194
github.com/segmentio/asm v1.2.0 // indirect
153
195
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
···
155
197
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
156
198
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
157
199
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
158
-
github.com/wyatt915/treeblood v0.1.15 // indirect
200
+
github.com/wyatt915/treeblood v0.1.16 // indirect
201
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
202
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect
159
203
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
160
204
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
205
+
go.etcd.io/bbolt v1.4.0 // indirect
161
206
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
162
207
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
163
208
go.opentelemetry.io/otel v1.37.0 // indirect
···
168
213
go.uber.org/atomic v1.11.0 // indirect
169
214
go.uber.org/multierr v1.11.0 // indirect
170
215
go.uber.org/zap v1.27.0 // indirect
171
-
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
172
216
golang.org/x/sys v0.34.0 // indirect
173
-
golang.org/x/text v0.27.0 // indirect
217
+
golang.org/x/text v0.29.0 // indirect
174
218
golang.org/x/time v0.12.0 // indirect
175
219
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
176
220
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+109
-16
go.sum
+109
-16
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=
12
14
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
13
15
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
14
16
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
···
19
21
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
20
22
github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
21
23
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=
22
26
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
23
27
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
24
28
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
25
29
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
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=
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=
28
71
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
29
72
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
30
73
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
31
74
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
32
75
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
76
+
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
77
+
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
33
78
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
34
79
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
35
80
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
···
48
93
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
49
94
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
50
95
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
96
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
97
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
98
+
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
99
+
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
100
+
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
101
+
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
102
+
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
103
+
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
104
+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
105
+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
106
+
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
107
+
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
51
108
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
52
109
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
53
110
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
···
120
177
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM=
121
178
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
122
179
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
180
+
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
181
+
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
123
182
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
124
183
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
125
184
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
···
136
195
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
137
196
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
138
197
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
198
+
github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A=
199
+
github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E=
139
200
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
140
201
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
141
202
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
···
152
213
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
153
214
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
154
215
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
216
+
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
217
+
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
218
+
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
219
+
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
155
220
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
156
221
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
157
222
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
···
163
228
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
164
229
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
165
230
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
231
+
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
166
232
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
167
233
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
168
234
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
243
309
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
244
310
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
245
311
github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY=
246
-
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
247
-
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
312
+
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
313
+
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
248
314
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
249
315
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
250
316
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
···
276
342
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
277
343
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
278
344
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
345
+
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
346
+
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
279
347
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
280
348
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
281
349
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
282
350
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
351
+
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
352
+
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
283
353
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
284
354
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
285
355
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
···
296
366
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
297
367
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
298
368
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
369
+
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
370
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
371
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
372
+
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
373
+
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
299
374
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
300
375
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
301
376
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
302
377
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
378
+
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
379
+
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
380
+
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
381
+
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
303
382
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
304
383
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
305
384
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
···
377
456
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
378
457
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
379
458
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
459
+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
460
+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
461
+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
380
462
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
381
463
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
382
464
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
···
399
481
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
400
482
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
401
483
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
484
+
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
485
+
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
486
+
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
487
+
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
402
488
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
403
489
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
404
490
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
···
426
512
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
427
513
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
428
514
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
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=
515
+
github.com/wyatt915/goldmark-treeblood v0.0.1 h1:6vLJcjFrHgE4ASu2ga4hqIQmbvQLU37v53jlHZ3pqDs=
516
+
github.com/wyatt915/goldmark-treeblood v0.0.1/go.mod h1:SmcJp5EBaV17rroNlgNQFydYwy0+fv85CUr/ZaCz208=
517
+
github.com/wyatt915/treeblood v0.1.16 h1:byxNbWZhnPDxdTp7W5kQhCeaY8RBVmojTFz1tEHgg8Y=
518
+
github.com/wyatt915/treeblood v0.1.16/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY=
519
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
520
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
433
521
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
434
522
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
435
523
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
436
524
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
437
525
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
438
526
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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=
527
+
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
528
+
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
441
529
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
442
530
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
531
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
532
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c=
443
533
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
444
534
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
445
535
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
446
536
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
537
+
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
538
+
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
447
539
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
448
540
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
449
541
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
···
489
581
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
490
582
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
491
583
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
584
+
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
585
+
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
492
586
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
493
587
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
494
588
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
···
528
622
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
529
623
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
530
624
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
531
-
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
532
-
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
625
+
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
626
+
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
533
627
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
534
628
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
535
629
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
583
677
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
584
678
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
585
679
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
586
-
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
587
-
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
680
+
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
681
+
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
588
682
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
589
683
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
590
684
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
···
645
739
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
646
740
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
647
741
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
742
+
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
648
743
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
649
744
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
650
745
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
···
652
747
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
653
748
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
654
749
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=
657
750
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
658
751
tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+36
-61
guard/guard.go
+36
-61
guard/guard.go
···
12
12
"os/exec"
13
13
"strings"
14
14
15
-
"github.com/bluesky-social/indigo/atproto/identity"
16
15
securejoin "github.com/cyphar/filepath-securejoin"
17
16
"github.com/urfave/cli/v3"
18
-
"tangled.org/core/idresolver"
19
17
"tangled.org/core/log"
20
18
)
21
19
···
93
91
"command", sshCommand,
94
92
"client", clientIP)
95
93
94
+
// TODO: greet user with their resolved handle instead of did
96
95
if sshCommand == "" {
97
96
l.Info("access denied: no interactive shells", "user", incomingUser)
98
97
fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
···
107
106
}
108
107
109
108
gitCommand := cmdParts[0]
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)
109
+
repoPath := cmdParts[1]
129
110
130
111
validCommands := map[string]bool{
131
112
"git-receive-pack": true,
···
138
119
return fmt.Errorf("access denied: invalid git command")
139
120
}
140
121
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
-
}
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)
149
128
}
150
129
151
-
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName)
130
+
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath)
152
131
153
132
l.Info("processing command",
154
133
"user", incomingUser,
155
134
"command", gitCommand,
156
-
"repo", repoName,
135
+
"repo", repoPath,
157
136
"fullPath", fullPath,
158
137
"client", clientIP)
159
138
···
177
156
gitCmd.Stdin = os.Stdin
178
157
gitCmd.Env = append(os.Environ(),
179
158
fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
180
-
fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()),
181
159
)
182
160
183
161
if err := gitCmd.Run(); err != nil {
···
189
167
l.Info("command completed",
190
168
"user", incomingUser,
191
169
"command", gitCommand,
192
-
"repo", repoName,
170
+
"repo", repoPath,
193
171
"success", true)
194
172
195
173
return nil
196
174
}
197
175
198
-
func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity {
199
-
resolver := idresolver.DefaultResolver()
200
-
ident, err := resolver.ResolveIdent(ctx, didOrHandle)
176
+
// runs guardAndQualifyRepo logic
177
+
func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) {
178
+
u, _ := url.Parse(endpoint + "/guard")
179
+
q := u.Query()
180
+
q.Add("user", incomingUser)
181
+
q.Add("repo", repo)
182
+
q.Add("gitCmd", gitCommand)
183
+
u.RawQuery = q.Encode()
184
+
185
+
resp, err := http.Get(u.String())
201
186
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)
187
+
return "", err
210
188
}
211
-
return ident
212
-
}
189
+
defer resp.Body.Close()
213
190
214
-
func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool {
215
-
u, _ := url.Parse(endpoint + "/push-allowed")
216
-
q := u.Query()
217
-
q.Add("user", user)
218
-
q.Add("repo", qualifiedRepoName)
219
-
u.RawQuery = q.Encode()
191
+
l.Info("Running guard", "url", u.String(), "status", resp.Status)
220
192
221
-
req, err := http.Get(u.String())
193
+
body, err := io.ReadAll(resp.Body)
222
194
if err != nil {
223
-
l.Error("Error verifying permissions", "error", err)
224
-
fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err)
225
-
os.Exit(1)
195
+
return "", err
226
196
}
227
-
228
-
l.Info("Checking push permission",
229
-
"url", u.String(),
230
-
"status", req.Status)
197
+
text := string(body)
231
198
232
-
return req.StatusCode == http.StatusNoContent
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
+
}
233
208
}
+17
-8
idresolver/resolver.go
+17
-8
idresolver/resolver.go
···
17
17
directory identity.Directory
18
18
}
19
19
20
-
func BaseDirectory() identity.Directory {
20
+
func BaseDirectory(plcUrl string) identity.Directory {
21
21
base := identity.BaseDirectory{
22
-
PLCURL: identity.DefaultPLCURL,
22
+
PLCURL: plcUrl,
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 string) (identity.Directory, error) {
45
+
func RedisDirectory(url, plcUrl 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(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
49
+
return redisdir.NewRedisDirectory(
50
+
BaseDirectory(plcUrl),
51
+
url,
52
+
hitTTL,
53
+
errTTL,
54
+
invalidHandleTTL,
55
+
10000,
56
+
)
50
57
}
51
58
52
-
func DefaultResolver() *Resolver {
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)
53
62
return &Resolver{
54
-
directory: identity.DefaultDirectory(),
63
+
directory: &cached,
55
64
}
56
65
}
57
66
58
-
func RedisResolver(redisUrl string) (*Resolver, error) {
59
-
directory, err := RedisDirectory(redisUrl)
67
+
func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) {
68
+
directory, err := RedisDirectory(redisUrl, plcUrl)
60
69
if err != nil {
61
70
return nil, err
62
71
}
+109
-12
input.css
+109
-12
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;
161
+
@apply no-underline;
162
+
}
163
+
164
+
.prose a.mention {
165
+
@apply no-underline hover:underline;
162
166
}
163
167
164
168
.prose li {
165
-
@apply my-0 py-0;
169
+
@apply my-0 py-0;
166
170
}
167
171
168
-
.prose ul, .prose ol {
169
-
@apply my-1 py-0;
172
+
.prose ul,
173
+
.prose ol {
174
+
@apply my-1 py-0;
170
175
}
171
176
172
177
.prose img {
···
176
181
}
177
182
178
183
.prose input {
179
-
@apply inline-block my-0 mb-1 mx-1;
184
+
@apply inline-block my-0 mb-1 mx-1;
180
185
}
181
186
182
187
.prose input[type="checkbox"] {
183
188
@apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500;
184
189
}
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
+
185
249
}
186
250
@layer utilities {
187
251
.error {
···
228
292
}
229
293
/* LineHighlight */
230
294
.chroma .hl {
231
-
@apply bg-amber-400/30 dark:bg-amber-500/20;
295
+
@apply bg-amber-400/30 dark:bg-amber-500/20;
232
296
}
233
297
234
298
/* LineNumbersTable */
···
865
929
text-decoration: underline;
866
930
}
867
931
}
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, log.New("jetstream"), sched)
117
+
client, err := client.NewClient(j.cfg, logger, sched)
118
118
if err != nil {
119
119
return fmt.Errorf("failed to create jetstream client: %w", err)
120
120
}
+2
-1
knotserver/config/config.go
+2
-1
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"`
22
23
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
23
24
Owner string `env:"OWNER, required"`
24
25
LogDids bool `env:"LOG_DIDS, default=true"`
···
41
42
Repo Repo `env:",prefix=KNOT_REPO_"`
42
43
Server Server `env:",prefix=KNOT_SERVER_"`
43
44
Git Git `env:",prefix=KNOT_GIT_"`
44
-
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"`
45
+
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"`
45
46
}
46
47
47
48
func Load(ctx context.Context) (*Config, error) {
+2
-3
knotserver/events.go
+2
-3
knotserver/events.go
···
8
8
"time"
9
9
10
10
"github.com/gorilla/websocket"
11
+
"tangled.org/core/log"
11
12
)
12
13
13
14
var upgrader = websocket.Upgrader{
···
16
17
}
17
18
18
19
func (h *Knot) Events(w http.ResponseWriter, r *http.Request) {
19
-
l := h.l.With("handler", "OpLog")
20
+
l := log.SubLogger(h.l, "eventstream")
20
21
l.Debug("received new connection")
21
22
22
23
conn, err := upgrader.Upgrade(w, r, nil)
···
75
76
}
76
77
case <-time.After(30 * time.Second):
77
78
// 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)
93
92
94
93
for _, event := range events {
95
94
// first extract the inner json into a map
+5
knotserver/git/branch.go
+5
knotserver/git/branch.go
+11
knotserver/git/git.go
+11
knotserver/git/git.go
···
71
71
return &g, nil
72
72
}
73
73
74
+
// re-open a repository and update references
75
+
func (g *GitRepo) Refresh() error {
76
+
refreshed, err := PlainOpen(g.path)
77
+
if err != nil {
78
+
return err
79
+
}
80
+
81
+
*g = *refreshed
82
+
return nil
83
+
}
84
+
74
85
func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) {
75
86
commits := []*object.Commit{}
76
87
+21
-2
knotserver/git/last_commit.go
+21
-2
knotserver/git/last_commit.go
···
30
30
commitCache = cache
31
31
}
32
32
33
-
func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.Reader, error) {
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) {
34
48
args := []string{}
35
49
args = append(args, "log")
36
50
args = append(args, g.h.String())
···
48
62
return nil, err
49
63
}
50
64
51
-
return stdout, nil
65
+
return &processReader{
66
+
Reader: stdout,
67
+
cmd: cmd,
68
+
stdout: stdout,
69
+
}, nil
52
70
}
53
71
54
72
type commit struct {
···
104
122
if err != nil {
105
123
return nil, err
106
124
}
125
+
defer output.Close() // Ensure the git process is properly cleaned up
107
126
108
127
reader := bufio.NewReader(output)
109
128
var current commit
+150
-37
knotserver/git/merge.go
+150
-37
knotserver/git/merge.go
···
4
4
"bytes"
5
5
"crypto/sha256"
6
6
"fmt"
7
+
"log"
7
8
"os"
8
9
"os/exec"
9
10
"regexp"
···
12
13
"github.com/dgraph-io/ristretto"
13
14
"github.com/go-git/go-git/v5"
14
15
"github.com/go-git/go-git/v5/plumbing"
16
+
"tangled.org/core/patchutil"
17
+
"tangled.org/core/types"
15
18
)
16
19
17
20
type MergeCheckCache struct {
···
32
35
mergeCheckCache = MergeCheckCache{cache}
33
36
}
34
37
35
-
func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string {
38
+
func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string {
36
39
sep := byte(':')
37
40
hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch))
38
41
return fmt.Sprintf("%x", hash)
···
49
52
}
50
53
}
51
54
52
-
func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) {
55
+
func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) {
53
56
key := m.cacheKey(g, patch, targetBranch)
54
57
val := m.cacheVal(mergeCheck)
55
58
m.cache.Set(key, val, 0)
56
59
}
57
60
58
-
func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) {
61
+
func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) {
59
62
key := m.cacheKey(g, patch, targetBranch)
60
63
if val, ok := m.cache.Get(key); ok {
61
64
if val == struct{}{} {
···
104
107
return fmt.Sprintf("merge failed: %s", e.Message)
105
108
}
106
109
107
-
func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) {
110
+
func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) {
108
111
tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
109
112
if err != nil {
110
113
return "", fmt.Errorf("failed to create temporary patch file: %w", err)
111
114
}
112
115
113
-
if _, err := tmpFile.Write(patchData); err != nil {
116
+
if _, err := tmpFile.Write([]byte(patchData)); err != nil {
114
117
tmpFile.Close()
115
118
os.Remove(tmpFile.Name())
116
119
return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
···
162
165
return nil
163
166
}
164
167
165
-
func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error {
168
+
func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error {
166
169
var stderr bytes.Buffer
167
170
var cmd *exec.Cmd
168
171
169
172
// configure default git user before merge
170
-
exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run()
171
-
exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run()
172
-
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
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()
173
176
174
177
// if patch is a format-patch, apply using 'git am'
175
178
if opts.FormatPatch {
176
-
cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
177
-
} else {
178
-
// else, apply using 'git apply' and commit it manually
179
-
applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
180
-
applyCmd.Stderr = &stderr
181
-
if err := applyCmd.Run(); err != nil {
182
-
return fmt.Errorf("patch application failed: %s", stderr.String())
183
-
}
179
+
return g.applyMailbox(patchData)
180
+
}
184
181
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
-
}
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
+
}
189
188
190
-
commitArgs := []string{"-C", tmpDir, "commit"}
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
+
}
191
193
192
-
// Set author if provided
193
-
authorName := opts.AuthorName
194
-
authorEmail := opts.AuthorEmail
194
+
commitArgs := []string{"-C", g.path, "commit"}
195
195
196
-
if authorName != "" && authorEmail != "" {
197
-
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
198
-
}
199
-
// else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
196
+
// Set author if provided
197
+
authorName := opts.AuthorName
198
+
authorEmail := opts.AuthorEmail
200
199
201
-
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
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
202
204
203
-
if opts.CommitBody != "" {
204
-
commitArgs = append(commitArgs, "-m", opts.CommitBody)
205
-
}
205
+
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
206
206
207
-
cmd = exec.Command("git", commitArgs...)
207
+
if opts.CommitBody != "" {
208
+
commitArgs = append(commitArgs, "-m", opts.CommitBody)
208
209
}
210
+
211
+
cmd = exec.Command("git", commitArgs...)
209
212
210
213
cmd.Stderr = &stderr
211
214
···
216
219
return nil
217
220
}
218
221
219
-
func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
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 {
220
328
if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
221
329
return val
222
330
}
···
244
352
return result
245
353
}
246
354
247
-
func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error {
355
+
func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error {
248
356
patchFile, err := g.createTempFileWithPatch(patchData)
249
357
if err != nil {
250
358
return &ErrMerge{
···
263
371
}
264
372
defer os.RemoveAll(tmpDir)
265
373
266
-
if err := g.applyPatch(tmpDir, patchFile, opts); err != nil {
374
+
tmpRepo, err := PlainOpen(tmpDir)
375
+
if err != nil {
376
+
return err
377
+
}
378
+
379
+
if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil {
267
380
return err
268
381
}
269
382
+18
-18
knotserver/git.go
+18
-18
knotserver/git.go
···
13
13
"tangled.org/core/knotserver/git/service"
14
14
)
15
15
16
-
func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
16
+
func (h *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
-
d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
22
+
h.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(d.c.Repo.ScanPath, repoName)
26
+
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName)
27
27
if err != nil {
28
28
gitError(w, "repository not found", http.StatusNotFound)
29
-
d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
29
+
h.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
-
d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err)
49
+
h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err)
50
50
return
51
51
}
52
52
case "git-receive-pack":
53
-
d.RejectPush(w, r, name)
53
+
h.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 (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
59
+
func (h *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(d.c.Repo.ScanPath, filepath.Join(did, name))
62
+
repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
63
63
if err != nil {
64
64
gitError(w, err.Error(), http.StatusInternalServerError)
65
-
d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
65
+
h.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
-
d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
80
+
h.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
-
d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
91
+
h.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
-
d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
103
+
h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
104
104
return
105
105
}
106
106
}
107
107
108
-
func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) {
108
+
func (h *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(d.c.Repo.ScanPath, filepath.Join(did, name))
111
+
_, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
112
112
if err != nil {
113
113
gitError(w, err.Error(), http.StatusForbidden)
114
-
d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err)
114
+
h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err)
115
115
return
116
116
}
117
117
118
-
d.RejectPush(w, r, name)
118
+
h.RejectPush(w, r, name)
119
119
}
120
120
121
-
func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
121
+
func (h *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 := d.c.Server.Hostname
134
+
hostname := h.c.Server.Hostname
135
135
if strings.Contains(hostname, ":") {
136
136
hostname = strings.Split(hostname, ":")[0]
137
137
}
+3
-7
knotserver/ingester.go
+3
-7
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"
20
19
"tangled.org/core/knotserver/db"
21
20
"tangled.org/core/knotserver/git"
22
21
"tangled.org/core/log"
···
120
119
}
121
120
122
121
// resolve this aturi to extract the repo record
123
-
resolver := idresolver.DefaultResolver()
124
-
ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
122
+
ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
125
123
if err != nil || ident.Handle.IsInvalidHandle() {
126
124
return fmt.Errorf("failed to resolve handle: %w", err)
127
125
}
···
233
231
return err
234
232
}
235
233
236
-
resolver := idresolver.DefaultResolver()
237
-
238
-
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
234
+
subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject)
239
235
if err != nil || subjectId.Handle.IsInvalidHandle() {
240
236
return err
241
237
}
242
238
243
239
// TODO: fix this for good, we need to fetch the record here unfortunately
244
240
// resolve this aturi to extract the repo record
245
-
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
241
+
owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
246
242
if err != nil || owner.Handle.IsInvalidHandle() {
247
243
return fmt.Errorf("failed to resolve handle: %w", err)
248
244
}
+153
-7
knotserver/internal.go
+153
-7
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"
16
17
"tangled.org/core/api/tangled"
17
18
"tangled.org/core/hook"
19
+
"tangled.org/core/idresolver"
18
20
"tangled.org/core/knotserver/config"
19
21
"tangled.org/core/knotserver/db"
20
22
"tangled.org/core/knotserver/git"
23
+
"tangled.org/core/log"
21
24
"tangled.org/core/notifier"
22
25
"tangled.org/core/rbac"
23
26
"tangled.org/core/workflow"
24
27
)
25
28
26
29
type InternalHandle struct {
27
-
db *db.DB
28
-
c *config.Config
29
-
e *rbac.Enforcer
30
-
l *slog.Logger
31
-
n *notifier.Notifier
30
+
db *db.DB
31
+
c *config.Config
32
+
e *rbac.Enforcer
33
+
l *slog.Logger
34
+
n *notifier.Notifier
35
+
res *idresolver.Resolver
32
36
}
33
37
34
38
func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
···
64
68
writeJSON(w, data)
65
69
}
66
70
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
+
67
131
type PushOptions struct {
68
132
skipCi bool
69
133
verboseCi bool
···
115
179
err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
116
180
if err != nil {
117
181
l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
182
+
// non-fatal
183
+
}
184
+
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)
118
188
// non-fatal
119
189
}
120
190
···
173
243
return errors.Join(errs, h.db.InsertEvent(event, h.n))
174
244
}
175
245
176
-
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
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 {
177
254
if pushOptions.skipCi {
178
255
return nil
179
256
}
···
268
345
return h.db.InsertEvent(event, h.n)
269
346
}
270
347
271
-
func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler {
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 {
272
413
r := chi.NewRouter()
414
+
l := log.FromContext(ctx)
415
+
l = log.SubLogger(l, "internal")
416
+
res := idresolver.DefaultResolver(c.Server.PlcUrl)
273
417
274
418
h := InternalHandle{
275
419
db,
···
277
421
e,
278
422
l,
279
423
n,
424
+
res,
280
425
}
281
426
282
427
r.Get("/push-allowed", h.PushAllowed)
283
428
r.Get("/keys", h.InternalKeys)
429
+
r.Get("/guard", h.Guard)
284
430
r.Post("/hooks/post-receive", h.PostReceiveHook)
285
431
r.Mount("/debug", middleware.Profiler())
286
432
+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
+
}
+18
-10
knotserver/router.go
+18
-10
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
-
tlog "tangled.org/core/log"
15
+
"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, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) {
32
-
r := chi.NewRouter()
33
-
31
+
func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier) (http.Handler, error) {
34
32
h := Knot{
35
33
c: c,
36
34
db: db,
37
35
e: e,
38
-
l: l,
36
+
l: log.FromContext(ctx),
39
37
jc: jc,
40
38
n: n,
41
-
resolver: idresolver.DefaultResolver(),
39
+
resolver: idresolver.DefaultResolver(c.Server.PlcUrl),
42
40
}
43
41
44
42
err := e.AddKnot(rbac.ThisServer)
···
67
65
return nil, fmt.Errorf("failed to start jetstream: %w", err)
68
66
}
69
67
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
+
70
77
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
71
78
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
72
79
})
···
86
93
// Socket that streams git oplogs
87
94
r.Get("/events", h.Events)
88
95
89
-
return r, nil
96
+
return r
90
97
}
91
98
92
99
func (h *Knot) XrpcRouter() http.Handler {
93
-
logger := tlog.New("knots")
94
-
95
100
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
101
+
102
+
l := log.SubLogger(h.l, "xrpc")
96
103
97
104
xrpc := &xrpc.Xrpc{
98
105
Config: h.c,
99
106
Db: h.db,
100
107
Ingester: h.jc,
101
108
Enforcer: h.e,
102
-
Logger: logger,
109
+
Logger: l,
103
110
Notifier: h.n,
104
111
Resolver: h.resolver,
105
112
ServiceAuth: serviceAuth,
106
113
}
114
+
107
115
return xrpc.Router()
108
116
}
109
117
+5
-4
knotserver/server.go
+5
-4
knotserver/server.go
···
43
43
44
44
func Run(ctx context.Context, cmd *cli.Command) error {
45
45
logger := log.FromContext(ctx)
46
-
iLogger := log.New("knotserver/internal")
46
+
logger = log.SubLogger(logger, cmd.Name)
47
+
ctx = log.IntoContext(ctx, logger)
47
48
48
49
c, err := config.Load(ctx)
49
50
if err != nil {
···
80
81
tangled.KnotMemberNSID,
81
82
tangled.RepoPullNSID,
82
83
tangled.RepoCollaboratorNSID,
83
-
}, nil, logger, db, true, c.Server.LogDids)
84
+
}, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids)
84
85
if err != nil {
85
86
logger.Error("failed to setup jetstream", "error", err)
86
87
}
87
88
88
89
notifier := notifier.New()
89
90
90
-
mux, err := Setup(ctx, c, db, e, jc, logger, ¬ifier)
91
+
mux, err := Setup(ctx, c, db, e, jc, ¬ifier)
91
92
if err != nil {
92
93
return fmt.Errorf("failed to setup server: %w", err)
93
94
}
94
95
95
-
imux := Internal(ctx, c, db, e, iLogger, ¬ifier)
96
+
imux := Internal(ctx, c, db, e, ¬ifier)
96
97
97
98
logger.Info("starting internal server", "address", c.Server.InternalListenAddr)
98
99
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([]byte(data.Patch), data.Branch, mo)
88
+
err = gr.MergeWithOptions(data.Patch, data.Branch, mo)
89
89
if err != nil {
90
90
var mergeErr *git.ErrMerge
91
91
if errors.As(err, &mergeErr) {
+3
-1
knotserver/xrpc/merge_check.go
+3
-1
knotserver/xrpc/merge_check.go
···
51
51
return
52
52
}
53
53
54
-
err = gr.MergeCheck([]byte(data.Patch), data.Branch)
54
+
err = gr.MergeCheck(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)
83
85
84
86
w.Header().Set("Content-Type", "application/json")
85
87
w.WriteHeader(http.StatusOK)
+20
-4
knotserver/xrpc/repo_compare.go
+20
-4
knotserver/xrpc/repo_compare.go
···
4
4
"fmt"
5
5
"net/http"
6
6
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
7
8
"tangled.org/core/knotserver/git"
8
9
"tangled.org/core/types"
9
10
xrpcerr "tangled.org/core/xrpc/errors"
···
71
72
return
72
73
}
73
74
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
+
74
88
response := types.RepoFormatPatchResponse{
75
-
Rev1: commit1.Hash.String(),
76
-
Rev2: commit2.Hash.String(),
77
-
FormatPatch: formatPatch,
78
-
Patch: rawPatch,
89
+
Rev1: commit1.Hash.String(),
90
+
Rev2: commit2.Hash.String(),
91
+
FormatPatch: formatPatch,
92
+
FormatPatchRaw: rawPatch,
93
+
CombinedPatch: combinedPatch,
94
+
CombinedPatchRaw: combinedPatchRaw,
79
95
}
80
96
81
97
writeJson(w, response)
+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)
41
42
r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo)
42
43
r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo)
43
44
r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
+5
lexicons/actor/profile.json
+5
lexicons/actor/profile.json
+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
+
},
35
50
"source": {
36
51
"type": "string",
37
52
"format": "uri",
+23
-9
log/log.go
+23
-9
log/log.go
···
4
4
"context"
5
5
"log/slog"
6
6
"os"
7
+
8
+
"github.com/charmbracelet/log"
7
9
)
8
10
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
-
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
13
-
Level: slog.LevelDebug,
12
+
return log.NewWithOptions(os.Stderr, log.Options{
13
+
ReportTimestamp: true,
14
+
Prefix: name,
15
+
Level: log.DebugLevel,
14
16
})
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
20
17
}
21
18
22
19
func New(name string) *slog.Logger {
···
49
46
50
47
return slog.Default()
51
48
}
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
+
}
+149
-17
nix/gomod2nix.toml
+149
-17
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="
16
19
[mod."github.com/alecthomas/assert/v2"]
17
20
version = "v2.11.0"
18
21
hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU="
···
29
32
[mod."github.com/avast/retry-go/v4"]
30
33
version = "v4.6.1"
31
34
hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k="
35
+
[mod."github.com/aymanbagabas/go-osc52/v2"]
36
+
version = "v2.0.1"
37
+
hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg="
32
38
[mod."github.com/aymerick/douceur"]
33
39
version = "v0.2.0"
34
40
hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE="
35
41
[mod."github.com/beorn7/perks"]
36
42
version = "v1.0.1"
37
43
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="
38
101
[mod."github.com/bluekeyes/go-gitdiff"]
39
102
version = "v0.8.2"
40
103
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
41
104
replaced = "tangled.sh/oppi.li/go-gitdiff"
42
105
[mod."github.com/bluesky-social/indigo"]
43
-
version = "v0.0.0-20250724221105-5827c8fb61bb"
44
-
hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI="
106
+
version = "v0.0.0-20251003000214-3259b215110e"
107
+
hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo="
45
108
[mod."github.com/bluesky-social/jetstream"]
46
109
version = "v0.0.0-20241210005130-ea96859b93d1"
47
110
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
48
111
[mod."github.com/bmatcuk/doublestar/v4"]
49
-
version = "v4.7.1"
50
-
hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA="
112
+
version = "v4.9.1"
113
+
hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE="
51
114
[mod."github.com/carlmjohnson/versioninfo"]
52
115
version = "v0.22.5"
53
116
hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw="
···
63
126
[mod."github.com/cespare/xxhash/v2"]
64
127
version = "v2.3.0"
65
128
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="
66
147
[mod."github.com/cloudflare/circl"]
67
148
version = "v1.6.2-0.20250618153321-aa837fd1539d"
68
149
hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y="
···
145
226
[mod."github.com/go-jose/go-jose/v3"]
146
227
version = "v3.0.4"
147
228
hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ="
229
+
[mod."github.com/go-logfmt/logfmt"]
230
+
version = "v0.6.0"
231
+
hash = "sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg="
148
232
[mod."github.com/go-logr/logr"]
149
233
version = "v1.4.3"
150
234
hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA="
···
163
247
[mod."github.com/gogo/protobuf"]
164
248
version = "v1.3.2"
165
249
hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c="
250
+
[mod."github.com/goki/freetype"]
251
+
version = "v1.0.5"
252
+
hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs="
166
253
[mod."github.com/golang-jwt/jwt/v5"]
167
254
version = "v5.2.3"
168
255
hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo="
···
172
259
[mod."github.com/golang/mock"]
173
260
version = "v1.6.0"
174
261
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="
175
268
[mod."github.com/google/go-querystring"]
176
269
version = "v1.1.0"
177
270
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
···
268
361
[mod."github.com/ipfs/go-metrics-interface"]
269
362
version = "v0.3.0"
270
363
hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ="
364
+
[mod."github.com/json-iterator/go"]
365
+
version = "v1.1.12"
366
+
hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM="
271
367
[mod."github.com/kevinburke/ssh_config"]
272
368
version = "v1.2.0"
273
369
hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s="
···
295
391
[mod."github.com/lestrrat-go/option"]
296
392
version = "v1.0.1"
297
393
hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI="
394
+
[mod."github.com/lucasb-eyer/go-colorful"]
395
+
version = "v1.2.0"
396
+
hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE="
298
397
[mod."github.com/mattn/go-isatty"]
299
398
version = "v0.0.20"
300
399
hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ="
400
+
[mod."github.com/mattn/go-runewidth"]
401
+
version = "v0.0.16"
402
+
hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ="
301
403
[mod."github.com/mattn/go-sqlite3"]
302
404
version = "v1.14.24"
303
405
hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg="
···
319
421
[mod."github.com/moby/term"]
320
422
version = "v0.5.2"
321
423
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="
322
430
[mod."github.com/morikuni/aec"]
323
431
version = "v1.0.0"
324
432
hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE="
325
433
[mod."github.com/mr-tron/base58"]
326
434
version = "v1.2.0"
327
435
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="
328
442
[mod."github.com/multiformats/go-base32"]
329
443
version = "v0.1.0"
330
444
hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio="
···
391
505
[mod."github.com/resend/resend-go/v2"]
392
506
version = "v2.15.0"
393
507
hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg="
508
+
[mod."github.com/rivo/uniseg"]
509
+
version = "v0.4.7"
510
+
hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo="
394
511
[mod."github.com/ryanuber/go-glob"]
395
512
version = "v1.0.0"
396
513
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
···
407
524
[mod."github.com/spaolacci/murmur3"]
408
525
version = "v1.1.0"
409
526
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="
410
533
[mod."github.com/stretchr/testify"]
411
534
version = "v1.10.0"
412
535
hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI="
···
426
549
version = "v0.3.1"
427
550
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
428
551
[mod."github.com/wyatt915/goldmark-treeblood"]
429
-
version = "v0.0.0-20250825231212-5dcbdb2f4b57"
430
-
hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM="
552
+
version = "v0.0.1"
553
+
hash = "sha256-hAVFaktO02MiiqZFffr8ZlvFEfwxw4Y84OZ2t7e5G7g="
431
554
[mod."github.com/wyatt915/treeblood"]
432
-
version = "v0.1.15"
433
-
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
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="
434
560
[mod."github.com/yuin/goldmark"]
435
-
version = "v1.7.12"
436
-
hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM="
561
+
version = "v1.7.13"
562
+
hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE="
437
563
[mod."github.com/yuin/goldmark-highlighting/v2"]
438
564
version = "v2.0.0-20230729083705-37449abec8cc"
439
565
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
566
+
[mod."gitlab.com/staticnoise/goldmark-callout"]
567
+
version = "v0.0.0-20240609120641-6366b799e4ab"
568
+
hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44="
440
569
[mod."gitlab.com/yawning/secp256k1-voi"]
441
570
version = "v0.0.0-20230925100816-f2616030848b"
442
571
hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
443
572
[mod."gitlab.com/yawning/tuplehash"]
444
573
version = "v0.0.0-20230713102510-df83abbf9a02"
445
574
hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato="
575
+
[mod."go.etcd.io/bbolt"]
576
+
version = "v1.4.0"
577
+
hash = "sha256-nR/YGQjwz6ue99IFbgw/01Pl8PhoOjpKiwVy5sJxlps="
446
578
[mod."go.opentelemetry.io/auto/sdk"]
447
579
version = "v1.1.0"
448
580
hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
···
479
611
[mod."golang.org/x/exp"]
480
612
version = "v0.0.0-20250620022241-b7579e27df2b"
481
613
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
614
+
[mod."golang.org/x/image"]
615
+
version = "v0.31.0"
616
+
hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg="
482
617
[mod."golang.org/x/net"]
483
618
version = "v0.42.0"
484
619
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
485
620
[mod."golang.org/x/sync"]
486
-
version = "v0.16.0"
487
-
hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks="
621
+
version = "v0.17.0"
622
+
hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0="
488
623
[mod."golang.org/x/sys"]
489
624
version = "v0.34.0"
490
625
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
491
626
[mod."golang.org/x/text"]
492
-
version = "v0.27.0"
493
-
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
627
+
version = "v0.29.0"
628
+
hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI="
494
629
[mod."golang.org/x/time"]
495
630
version = "v0.12.0"
496
631
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
···
527
662
[mod."lukechampine.com/blake3"]
528
663
version = "v1.4.1"
529
664
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="
+285
-18
nix/modules/appview.nix
+285
-18
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
+
16
17
package = mkOption {
17
18
type = types.package;
18
19
description = "Package to use for the appview";
19
20
};
21
+
22
+
# core configuration
20
23
port = mkOption {
21
-
type = types.int;
24
+
type = types.port;
22
25
default = 3000;
23
26
description = "Port to run the appview on";
24
27
};
25
-
cookie_secret = mkOption {
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 {
26
36
type = types.str;
27
-
default = "00000000000000000000000000000000";
28
-
description = "Cookie secret";
37
+
default = "/var/lib/appview/appview.db";
38
+
description = "Path to the SQLite database file";
39
+
};
40
+
41
+
appviewHost = mkOption {
42
+
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
+
};
154
+
};
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
+
};
29
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
+
30
228
environmentFile = mkOption {
31
229
type = with types; nullOr path;
32
230
default = null;
33
-
example = "/etc/tangled-appview.env";
231
+
example = "/etc/appview.env";
34
232
description = ''
35
233
Additional environment file as defined in {manpage}`systemd.exec(5)`.
36
234
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
-
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.
41
245
'';
42
246
};
43
247
};
44
248
};
45
249
46
250
config = mkIf cfg.enable {
47
-
systemd.services.tangled-appview = {
251
+
services.redis.servers.appview = {
252
+
enable = true;
253
+
port = 6379;
254
+
};
255
+
256
+
systemd.services.appview = {
48
257
description = "tangled appview service";
49
258
wantedBy = ["multi-user.target"];
259
+
after = ["redis-appview.service" "network-online.target"];
260
+
requires = ["redis-appview.service"];
261
+
wants = ["network-online.target"];
50
262
51
263
serviceConfig = {
52
-
ListenStream = "0.0.0.0:${toString cfg.port}";
264
+
Type = "simple";
53
265
ExecStart = "${cfg.package}/bin/appview";
54
266
Restart = "always";
55
-
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
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"];
56
280
};
57
281
58
-
environment = {
59
-
TANGLED_DB_PATH = "appview.db";
60
-
TANGLED_COOKIE_SECRET = cfg.cookie_secret;
61
-
};
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
+
};
62
329
};
63
330
};
64
331
}
+76
-6
nix/modules/knot.nix
+76
-6
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.sh";
25
+
default = "https://tangled.org";
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
+
54
75
mainBranch = mkOption {
55
76
type = types.str;
56
77
default = "main";
57
78
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";
58
93
};
59
94
};
60
95
···
107
142
108
143
hostname = mkOption {
109
144
type = types.str;
110
-
example = "knot.tangled.sh";
145
+
example = "my.knot.com";
111
146
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";
112
165
};
113
166
114
167
dev = mkOption {
···
178
231
mkdir -p "${cfg.stateDir}/.config/git"
179
232
cat > "${cfg.stateDir}/.config/git/config" << EOF
180
233
[user]
181
-
name = Git User
182
-
email = git@example.com
234
+
name = ${cfg.git.userName}
235
+
email = ${cfg.git.userEmail}
183
236
[receive]
184
237
advertisePushOptions = true
238
+
[uploadpack]
239
+
allowFilter = true
185
240
EOF
186
241
${setMotd}
187
242
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
···
193
248
WorkingDirectory = cfg.stateDir;
194
249
Environment = [
195
250
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
251
+
"KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}"
196
252
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
253
+
"KNOT_GIT_USER_NAME=${cfg.git.userName}"
254
+
"KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}"
197
255
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
198
256
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
199
257
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
200
258
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
201
259
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
260
+
"KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}"
261
+
"KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
202
262
"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
+
}"
203
273
];
204
274
ExecStart = "${cfg.package}/bin/knot server";
205
275
Restart = "always";
+12
-5
nix/modules/spindle.nix
+12
-5
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 = "spindle.tangled.sh";
36
+
example = "my.spindle.com";
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
+
40
46
jetstreamEndpoint = mkOption {
41
47
type = types.str;
42
48
default = "wss://jetstream1.us-west.bsky.network/subscribe";
···
92
98
pipelines = {
93
99
nixery = mkOption {
94
100
type = types.str;
95
-
default = "nixery.tangled.sh";
101
+
default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet
96
102
description = "Nixery instance to use";
97
103
};
98
104
···
119
125
"SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
120
126
"SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}"
121
127
"SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
122
-
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
128
+
"SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}"
129
+
"SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
123
130
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
124
131
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
125
132
"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,
8
9
sqlite-lib,
9
10
tailwindcss,
10
11
src,
···
22
23
cp -rf ${lucide-src}/*.svg icons/
23
24
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
24
25
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
26
+
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
25
27
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
28
+
cp -f ${actor-typeahead-src}/actor-typeahead.js .
26
29
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
27
30
# for whatever reason (produces broken css), so we are doing this instead
28
31
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
+21
-8
nix/vm.nix
+21
-8
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";
13
22
in
14
23
nixpkgs.lib.nixosSystem {
15
24
inherit system;
···
73
82
time.timeZone = "Europe/London";
74
83
services.getty.autologinUser = "root";
75
84
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
76
-
services.tangled-knot = {
85
+
services.tangled.knot = {
77
86
enable = true;
78
87
motd = "Welcome to the development knot!\n";
79
88
server = {
80
89
owner = envVar "TANGLED_VM_KNOT_OWNER";
81
-
hostname = "localhost:6000";
90
+
hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6000";
91
+
plcUrl = plcUrl;
92
+
jetstreamEndpoint = jetstream;
82
93
listenAddr = "0.0.0.0:6000";
83
94
};
84
95
};
85
-
services.tangled-spindle = {
96
+
services.tangled.spindle = {
86
97
enable = true;
87
98
server = {
88
99
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
89
-
hostname = "localhost:6555";
100
+
hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
101
+
plcUrl = plcUrl;
102
+
jetstreamEndpoint = jetstream;
90
103
listenAddr = "0.0.0.0:6555";
91
104
dev = true;
92
105
queueSize = 100;
···
99
112
users = {
100
113
# So we don't have to deal with permission clashing between
101
114
# blank disk VMs and existing state
102
-
users.${config.services.tangled-knot.gitUser}.uid = 666;
103
-
groups.${config.services.tangled-knot.gitUser}.gid = 666;
115
+
users.${config.services.tangled.knot.gitUser}.uid = 666;
116
+
groups.${config.services.tangled.knot.gitUser}.gid = 666;
104
117
105
118
# TODO: separate spindle user
106
119
};
···
120
133
serviceConfig.PermissionsStartOnly = true;
121
134
};
122
135
in {
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);
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);
125
138
};
126
139
})
127
140
];
+18
-7
patchutil/patchutil.go
+18
-7
patchutil/patchutil.go
···
1
1
package patchutil
2
2
3
3
import (
4
+
"errors"
4
5
"fmt"
5
6
"log"
6
7
"os"
···
42
43
// IsPatchValid checks if the given patch string is valid.
43
44
// It performs very basic sniffing for either git-diff or git-format-patch
44
45
// header lines. For format patches, it attempts to extract and validate each one.
45
-
func IsPatchValid(patch string) bool {
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 {
46
53
if len(patch) == 0 {
47
-
return false
54
+
return EmptyPatchError
48
55
}
49
56
50
57
lines := strings.Split(patch, "\n")
51
58
if len(lines) < 2 {
52
-
return false
59
+
return EmptyPatchError
53
60
}
54
61
55
62
firstLine := strings.TrimSpace(lines[0])
···
60
67
strings.HasPrefix(firstLine, "Index: ") ||
61
68
strings.HasPrefix(firstLine, "+++ ") ||
62
69
strings.HasPrefix(firstLine, "@@ ") {
63
-
return true
70
+
return nil
64
71
}
65
72
66
73
// check if it's format-patch
···
70
77
// it's safe to say it's broken.
71
78
patches, err := ExtractPatches(patch)
72
79
if err != nil {
73
-
return false
80
+
return fmt.Errorf("%w: %w", FormatPatchError, err)
74
81
}
75
-
return len(patches) > 0
82
+
if len(patches) == 0 {
83
+
return EmptyPatchError
84
+
}
85
+
86
+
return nil
76
87
}
77
88
78
-
return false
89
+
return GenericPatchError
79
90
}
80
91
81
92
func IsFormatPatch(patch string) bool {
+13
-12
patchutil/patchutil_test.go
+13
-12
patchutil/patchutil_test.go
···
1
1
package patchutil
2
2
3
3
import (
4
+
"errors"
4
5
"reflect"
5
6
"testing"
6
7
)
···
9
10
tests := []struct {
10
11
name string
11
12
patch string
12
-
expected bool
13
+
expected error
13
14
}{
14
15
{
15
16
name: `empty patch`,
16
17
patch: ``,
17
-
expected: false,
18
+
expected: EmptyPatchError,
18
19
},
19
20
{
20
21
name: `single line patch`,
21
22
patch: `single line`,
22
-
expected: false,
23
+
expected: EmptyPatchError,
23
24
},
24
25
{
25
26
name: `valid diff patch`,
···
31
32
-old line
32
33
+new line
33
34
context`,
34
-
expected: true,
35
+
expected: nil,
35
36
},
36
37
{
37
38
name: `valid patch starting with ---`,
···
41
42
-old line
42
43
+new line
43
44
context`,
44
-
expected: true,
45
+
expected: nil,
45
46
},
46
47
{
47
48
name: `valid patch starting with Index`,
···
53
54
-old line
54
55
+new line
55
56
context`,
56
-
expected: true,
57
+
expected: nil,
57
58
},
58
59
{
59
60
name: `valid patch starting with +++`,
···
63
64
-old line
64
65
+new line
65
66
context`,
66
-
expected: true,
67
+
expected: nil,
67
68
},
68
69
{
69
70
name: `valid patch starting with @@`,
···
72
73
+new line
73
74
context
74
75
`,
75
-
expected: true,
76
+
expected: nil,
76
77
},
77
78
{
78
79
name: `valid format patch`,
···
90
91
+new content
91
92
--
92
93
2.48.1`,
93
-
expected: true,
94
+
expected: nil,
94
95
},
95
96
{
96
97
name: `invalid format patch`,
97
98
patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001
98
99
From: Author <author@example.com>
99
100
This is not a valid patch format`,
100
-
expected: false,
101
+
expected: FormatPatchError,
101
102
},
102
103
{
103
104
name: `not a patch at all`,
···
105
106
just some
106
107
random text
107
108
that isn't a patch`,
108
-
expected: false,
109
+
expected: GenericPatchError,
109
110
},
110
111
}
111
112
112
113
for _, tt := range tests {
113
114
t.Run(tt.name, func(t *testing.T) {
114
115
result := IsPatchValid(tt.patch)
115
-
if result != tt.expected {
116
+
if !errors.Is(result, tt.expected) {
116
117
t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected)
117
118
}
118
119
})
-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"`
16
17
Dev bool `env:"DEV, default=false"`
17
18
Owner string `env:"OWNER, required"`
18
19
Secrets Secrets `env:",prefix=SECRETS_"`
+13
-3
spindle/engine/engine.go
+13
-3
spindle/engine/engine.go
···
79
79
defer cancel()
80
80
81
81
for stepIdx, step := range w.Steps {
82
+
// log start of step
82
83
if wfLogger != nil {
83
-
ctl := wfLogger.ControlWriter(stepIdx, step)
84
-
ctl.Write([]byte(step.Name()))
84
+
wfLogger.
85
+
ControlWriter(stepIdx, step, models.StepStatusStart).
86
+
Write([]byte{0})
85
87
}
86
88
87
89
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
+
88
98
if err != nil {
89
99
if errors.Is(err, ErrTimedOut) {
90
100
dbErr := db.StatusTimeout(wid, n)
···
115
125
if err := eg.Wait(); err != nil {
116
126
l.Error("failed to run one or more workflows", "err", err)
117
127
} else {
118
-
l.Error("successfully ran full pipeline")
128
+
l.Info("successfully ran full pipeline")
119
129
}
120
130
}
+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"},
225
+
CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"},
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("stdout"),
385
-
wfLogger.DataWriter("stderr"),
384
+
wfLogger.DataWriter(stepIdx, "stdout"),
385
+
wfLogger.DataWriter(stepIdx, "stderr"),
386
386
logs.Reader,
387
387
)
388
388
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
+3
-7
spindle/ingester.go
+3
-7
spindle/ingester.go
···
9
9
10
10
"tangled.org/core/api/tangled"
11
11
"tangled.org/core/eventconsumer"
12
-
"tangled.org/core/idresolver"
13
12
"tangled.org/core/rbac"
14
13
"tangled.org/core/spindle/db"
15
14
···
142
141
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
143
142
var err error
144
143
did := e.Did
145
-
resolver := idresolver.DefaultResolver()
146
144
147
145
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
148
146
···
190
188
}
191
189
192
190
// add collaborators to rbac
193
-
owner, err := resolver.ResolveIdent(ctx, did)
191
+
owner, err := s.res.ResolveIdent(ctx, did)
194
192
if err != nil || owner.Handle.IsInvalidHandle() {
195
193
return err
196
194
}
···
225
223
return err
226
224
}
227
225
228
-
resolver := idresolver.DefaultResolver()
229
-
230
-
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
226
+
subjectId, err := s.res.ResolveIdent(ctx, record.Subject)
231
227
if err != nil || subjectId.Handle.IsInvalidHandle() {
232
228
return err
233
229
}
···
240
236
241
237
// TODO: get rid of this entirely
242
238
// resolve this aturi to extract the repo record
243
-
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
239
+
owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String())
244
240
if err != nil || owner.Handle.IsInvalidHandle() {
245
241
return fmt.Errorf("failed to resolve handle: %w", err)
246
242
}
+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
+
}
+14
-11
spindle/models/logger.go
+14
-11
spindle/models/logger.go
···
37
37
return l.file.Close()
38
38
}
39
39
40
-
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
41
-
// TODO: emit stream
40
+
func (l *WorkflowLogger) DataWriter(idx int, stream string) io.Writer {
42
41
return &dataWriter{
43
42
logger: l,
43
+
idx: idx,
44
44
stream: stream,
45
45
}
46
46
}
47
47
48
-
func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer {
48
+
func (l *WorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer {
49
49
return &controlWriter{
50
-
logger: l,
51
-
idx: idx,
52
-
step: step,
50
+
logger: l,
51
+
idx: idx,
52
+
step: step,
53
+
stepStatus: stepStatus,
53
54
}
54
55
}
55
56
56
57
type dataWriter struct {
57
58
logger *WorkflowLogger
59
+
idx int
58
60
stream string
59
61
}
60
62
61
63
func (w *dataWriter) Write(p []byte) (int, error) {
62
64
line := strings.TrimRight(string(p), "\r\n")
63
-
entry := NewDataLogLine(line, w.stream)
65
+
entry := NewDataLogLine(w.idx, line, w.stream)
64
66
if err := w.logger.encoder.Encode(entry); err != nil {
65
67
return 0, err
66
68
}
···
68
70
}
69
71
70
72
type controlWriter struct {
71
-
logger *WorkflowLogger
72
-
idx int
73
-
step Step
73
+
logger *WorkflowLogger
74
+
idx int
75
+
step Step
76
+
stepStatus StepStatus
74
77
}
75
78
76
79
func (w *controlWriter) Write(_ []byte) (int, error) {
77
-
entry := NewControlLogLine(w.idx, w.step)
80
+
entry := NewControlLogLine(w.idx, w.step, w.stepStatus)
78
81
if err := w.logger.encoder.Encode(entry); err != nil {
79
82
return 0, err
80
83
}
+23
-8
spindle/models/models.go
+23
-8
spindle/models/models.go
···
4
4
"fmt"
5
5
"regexp"
6
6
"slices"
7
+
"time"
7
8
8
9
"tangled.org/core/api/tangled"
9
10
···
76
77
var (
77
78
// step log data
78
79
LogKindData LogKind = "data"
79
-
// indicates start/end of a step
80
+
// indicates status of a step
80
81
LogKindControl LogKind = "control"
81
82
)
82
83
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
+
83
92
type LogLine struct {
84
-
Kind LogKind `json:"kind"`
85
-
Content string `json:"content"`
93
+
Kind LogKind `json:"kind"`
94
+
Content string `json:"content"`
95
+
Time time.Time `json:"time"`
96
+
StepId int `json:"step_id"`
86
97
87
98
// fields if kind is "data"
88
99
Stream string `json:"stream,omitempty"`
89
100
90
101
// fields if kind is "control"
91
-
StepId int `json:"step_id,omitempty"`
92
-
StepKind StepKind `json:"step_kind,omitempty"`
93
-
StepCommand string `json:"step_command,omitempty"`
102
+
StepStatus StepStatus `json:"step_status,omitempty"`
103
+
StepKind StepKind `json:"step_kind,omitempty"`
104
+
StepCommand string `json:"step_command,omitempty"`
94
105
}
95
106
96
-
func NewDataLogLine(content, stream string) LogLine {
107
+
func NewDataLogLine(idx int, content, stream string) LogLine {
97
108
return LogLine{
98
109
Kind: LogKindData,
110
+
Time: time.Now(),
99
111
Content: content,
112
+
StepId: idx,
100
113
Stream: stream,
101
114
}
102
115
}
103
116
104
-
func NewControlLogLine(idx int, step Step) LogLine {
117
+
func NewControlLogLine(idx int, step Step, status StepStatus) LogLine {
105
118
return LogLine{
106
119
Kind: LogKindControl,
120
+
Time: time.Now(),
107
121
Content: step.Name(),
108
122
StepId: idx,
123
+
StepStatus: status,
109
124
StepKind: step.Kind(),
110
125
StepCommand: step.Command(),
111
126
}
+92
-47
spindle/server.go
+92
-47
spindle/server.go
···
49
49
vault secrets.Manager
50
50
}
51
51
52
-
func Run(ctx context.Context) error {
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) {
53
54
logger := log.FromContext(ctx)
54
55
55
-
cfg, err := config.Load(ctx)
56
-
if err != nil {
57
-
return fmt.Errorf("failed to load config: %w", err)
58
-
}
59
-
60
56
d, err := db.Make(cfg.Server.DBPath)
61
57
if err != nil {
62
-
return fmt.Errorf("failed to setup db: %w", err)
58
+
return nil, fmt.Errorf("failed to setup db: %w", err)
63
59
}
64
60
65
61
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
66
62
if err != nil {
67
-
return fmt.Errorf("failed to setup rbac enforcer: %w", err)
63
+
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
68
64
}
69
65
e.E.EnableAutoSave(true)
70
66
···
74
70
switch cfg.Server.Secrets.Provider {
75
71
case "openbao":
76
72
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
77
-
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
73
+
return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
78
74
}
79
75
vault, err = secrets.NewOpenBaoManager(
80
76
cfg.Server.Secrets.OpenBao.ProxyAddr,
···
82
78
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
83
79
)
84
80
if err != nil {
85
-
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
81
+
return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err)
86
82
}
87
83
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
88
84
case "sqlite", "":
89
85
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
90
86
if err != nil {
91
-
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
87
+
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
92
88
}
93
89
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
94
90
default:
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
91
+
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
101
92
}
102
93
103
94
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
···
108
99
tangled.RepoNSID,
109
100
tangled.RepoCollaboratorNSID,
110
101
}
111
-
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
102
+
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
112
103
if err != nil {
113
-
return fmt.Errorf("failed to setup jetstream client: %w", err)
104
+
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
114
105
}
115
106
jc.AddDid(cfg.Server.Owner)
116
107
117
108
// Check if the spindle knows about any Dids;
118
109
dids, err := d.GetAllDids()
119
110
if err != nil {
120
-
return fmt.Errorf("failed to get all dids: %w", err)
111
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
121
112
}
122
113
for _, d := range dids {
123
114
jc.AddDid(d)
124
115
}
125
116
126
-
resolver := idresolver.DefaultResolver()
117
+
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
127
118
128
-
spindle := Spindle{
119
+
spindle := &Spindle{
129
120
jc: jc,
130
121
e: e,
131
122
db: d,
132
123
l: logger,
133
124
n: &n,
134
-
engs: map[string]models.Engine{"nixery": nixeryEng},
125
+
engs: engines,
135
126
jq: jq,
136
127
cfg: cfg,
137
128
res: resolver,
···
140
131
141
132
err = e.AddSpindle(rbacDomain)
142
133
if err != nil {
143
-
return fmt.Errorf("failed to set rbac domain: %w", err)
134
+
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
144
135
}
145
136
err = spindle.configureOwner()
146
137
if err != nil {
147
-
return err
138
+
return nil, err
148
139
}
149
140
logger.Info("owner set", "did", cfg.Server.Owner)
150
141
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
-
160
142
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
161
143
if err != nil {
162
-
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
144
+
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
163
145
}
164
146
165
147
err = jc.StartJetstream(ctx, spindle.ingest())
166
148
if err != nil {
167
-
return fmt.Errorf("failed to start jetstream consumer: %w", err)
149
+
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
168
150
}
169
151
170
152
// for each incoming sh.tangled.pipeline, we execute
171
153
// spindle.processPipeline, which in turn enqueues the pipeline
172
154
// job in the above registered queue.
173
155
ccfg := eventconsumer.NewConsumerConfig()
174
-
ccfg.Logger = logger
156
+
ccfg.Logger = log.SubLogger(logger, "eventconsumer")
175
157
ccfg.Dev = cfg.Server.Dev
176
158
ccfg.ProcessFunc = spindle.processPipeline
177
159
ccfg.CursorStore = cursorStore
178
160
knownKnots, err := d.Knots()
179
161
if err != nil {
180
-
return err
162
+
return nil, err
181
163
}
182
164
for _, knot := range knownKnots {
183
165
logger.Info("adding source start", "knot", knot)
···
185
167
}
186
168
spindle.ks = eventconsumer.NewConsumer(*ccfg)
187
169
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
+
188
214
go func() {
189
-
logger.Info("starting knot event consumer")
190
-
spindle.ks.Start(ctx)
215
+
s.l.Info("starting knot event consumer")
216
+
s.ks.Start(ctx)
191
217
}()
192
218
193
-
logger.Info("starting spindle server", "address", cfg.Server.ListenAddr)
194
-
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router()))
219
+
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
220
+
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
221
+
}
222
+
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
+
}
195
240
196
-
return nil
241
+
return s.Start(ctx)
197
242
}
198
243
199
244
func (s *Spindle) Router() http.Handler {
···
210
255
}
211
256
212
257
func (s *Spindle) XrpcRouter() http.Handler {
213
-
logger := s.l.With("route", "xrpc")
214
-
215
258
serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String())
216
259
260
+
l := log.SubLogger(s.l, "xrpc")
261
+
217
262
x := xrpc.Xrpc{
218
-
Logger: logger,
263
+
Logger: l,
219
264
Db: s.db,
220
265
Enforcer: s.e,
221
266
Engines: s.engs,
···
305
350
306
351
ok := s.jq.Enqueue(queue.Job{
307
352
Run: func() error {
308
-
engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
353
+
engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
309
354
RepoOwner: tpl.TriggerMetadata.Repo.Did,
310
355
RepoName: tpl.TriggerMetadata.Repo.Repo,
311
356
Workflows: workflows,
+8
-3
spindle/stream.go
+8
-3
spindle/stream.go
···
10
10
"strconv"
11
11
"time"
12
12
13
+
"tangled.org/core/log"
13
14
"tangled.org/core/spindle/models"
14
15
15
16
"github.com/go-chi/chi/v5"
···
23
24
}
24
25
25
26
func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) {
26
-
l := s.l.With("handler", "Events")
27
+
l := log.SubLogger(s.l, "eventstream")
28
+
27
29
l.Debug("received new connection")
28
30
29
31
conn, err := upgrader.Upgrade(w, r, nil)
···
82
84
}
83
85
case <-time.After(30 * time.Second):
84
86
// send a keep-alive
85
-
l.Debug("sent keepalive")
86
87
if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
87
88
l.Error("failed to write control", "err", err)
88
89
}
···
212
213
if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil {
213
214
return fmt.Errorf("failed to write to websocket: %w", err)
214
215
}
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
+
}
215
221
}
216
222
}
217
223
}
···
222
228
s.l.Debug("err", "err", err)
223
229
return err
224
230
}
225
-
s.l.Debug("ops", "ops", events)
226
231
227
232
for _, event := range events {
228
233
// first extract the inner json into a map
+7
-5
types/repo.go
+7
-5
types/repo.go
···
1
1
package types
2
2
3
3
import (
4
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
4
5
"github.com/go-git/go-git/v5/plumbing/object"
5
6
)
6
7
···
33
34
}
34
35
35
36
type RepoFormatPatchResponse struct {
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"`
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"`
41
43
}
42
44
43
45
type RepoTreeResponse struct {
+9
-1
workflow/compile.go
+9
-1
workflow/compile.go
···
113
113
func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow {
114
114
cw := &tangled.Pipeline_Workflow{}
115
115
116
-
if !w.Match(compiler.Trigger) {
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 {
117
125
compiler.Diagnostics.AddWarning(
118
126
w.Name,
119
127
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
+
}
+61
-19
workflow/def.go
+61
-19
workflow/def.go
···
8
8
9
9
"tangled.org/core/api/tangled"
10
10
11
+
"github.com/bmatcuk/doublestar/v4"
11
12
"github.com/go-git/go-git/v5/plumbing"
12
13
"gopkg.in/yaml.v3"
13
14
)
···
33
34
34
35
Constraint struct {
35
36
Event StringList `yaml:"event"`
36
-
Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events
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
37
39
}
38
40
39
41
CloneOpts struct {
···
59
61
return strings.ReplaceAll(string(t), "_", " ")
60
62
}
61
63
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
+
62
81
func FromFile(name string, contents []byte) (Workflow, error) {
63
82
var wf Workflow
64
83
···
74
93
}
75
94
76
95
// if any of the constraints on a workflow is true, return true
77
-
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
96
+
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
78
97
// manual triggers always run the workflow
79
98
if trigger.Manual != nil {
80
-
return true
99
+
return true, nil
81
100
}
82
101
83
102
// if not manual, run through the constraint list and see if any one matches
84
103
for _, c := range w.When {
85
-
if c.Match(trigger) {
86
-
return true
104
+
matched, err := c.Match(trigger)
105
+
if err != nil {
106
+
return false, err
107
+
}
108
+
if matched {
109
+
return true, nil
87
110
}
88
111
}
89
112
90
113
// no constraints, always run this workflow
91
114
if len(w.When) == 0 {
92
-
return true
115
+
return true, nil
93
116
}
94
117
95
-
return false
118
+
return false, nil
96
119
}
97
120
98
-
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
121
+
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
99
122
match := true
100
123
101
124
// manual triggers always pass this constraint
102
125
if trigger.Manual != nil {
103
-
return true
126
+
return true, nil
104
127
}
105
128
106
129
// apply event constraints
···
108
131
109
132
// apply branch constraints for PRs
110
133
if trigger.PullRequest != nil {
111
-
match = match && c.MatchBranch(trigger.PullRequest.TargetBranch)
134
+
matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch)
135
+
if err != nil {
136
+
return false, err
137
+
}
138
+
match = match && matched
112
139
}
113
140
114
141
// apply ref constraints for pushes
115
142
if trigger.Push != nil {
116
-
match = match && c.MatchRef(trigger.Push.Ref)
143
+
matched, err := c.MatchRef(trigger.Push.Ref)
144
+
if err != nil {
145
+
return false, err
146
+
}
147
+
match = match && matched
117
148
}
118
149
119
-
return match
120
-
}
121
-
122
-
func (c *Constraint) MatchBranch(branch string) bool {
123
-
return slices.Contains(c.Branch, branch)
150
+
return match, nil
124
151
}
125
152
126
-
func (c *Constraint) MatchRef(ref string) bool {
153
+
func (c *Constraint) MatchRef(ref string) (bool, error) {
127
154
refName := plumbing.ReferenceName(ref)
155
+
shortName := refName.Short()
156
+
128
157
if refName.IsBranch() {
129
-
return slices.Contains(c.Branch, refName.Short())
158
+
return c.MatchBranch(shortName)
130
159
}
131
-
return false
160
+
161
+
if refName.IsTag() {
162
+
return c.MatchTag(shortName)
163
+
}
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)
132
174
}
133
175
134
176
func (c *Constraint) MatchEvent(event string) bool {
+284
-1
workflow/def_test.go
+284
-1
workflow/def_test.go
···
6
6
"github.com/stretchr/testify/assert"
7
7
)
8
8
9
-
func TestUnmarshalWorkflow(t *testing.T) {
9
+
func TestUnmarshalWorkflowWithBranch(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
+
}
+5
-4
xrpc/serviceauth/service_auth.go
+5
-4
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"
12
13
xrpcerr "tangled.org/core/xrpc/errors"
13
14
)
14
15
···
22
23
23
24
func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth {
24
25
return &ServiceAuth{
25
-
logger: logger,
26
+
logger: log.SubLogger(logger, "serviceauth"),
26
27
resolver: resolver,
27
28
audienceDid: audienceDid,
28
29
}
···
30
31
31
32
func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler {
32
33
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33
-
l := sa.logger.With("url", r.URL)
34
-
35
34
token := r.Header.Get("Authorization")
36
35
token = strings.TrimPrefix(token, "Bearer ")
37
36
···
42
41
43
42
did, err := s.Validate(r.Context(), token, nil)
44
43
if err != nil {
45
-
l.Error("signature verification failed", "err", err)
44
+
sa.logger.Error("signature verification failed", "err", err)
46
45
writeError(w, xrpcerr.AuthError(err), http.StatusForbidden)
47
46
return
48
47
}
48
+
49
+
sa.logger.Debug("valid signature", ActorDid, did)
49
50
50
51
r = r.WithContext(
51
52
context.WithValue(r.Context(), ActorDid, did),